/* ===== BLUEPRINT DESIGN SYSTEM =====
   Used by the redesigned pages (index.html, fulltrace.html).
   Pages on this stylesheet do not load main.css. */

/* ===== TOKENS ===== */
:root {
  color-scheme: dark;
  --nav-h: 72px;
  --anchor-offset: 60px;
  --accent: #8b93e8;
  --accent-text: #8b93e8;
  /* The accent as ink: strokes, arrowheads, anything drawn thin rather than filled.
     On dark it is the accent itself. On light it has to be darkened (see below), and
     splitting it out is what lets that happen without dragging the tints, badges and
     button fills down with it, which want to stay light. */
  --accent-line: var(--accent);
  --bg: #0c0e14;
  --bg-panel: #10121a;
  --nav-bg: rgba(12,14,20,0.92);
  --card: rgba(255,255,255,0.03);
  --card-hover: rgba(255,255,255,0.045);
  --row-hover: rgba(255,255,255,0.025);
  --heading: #eef1f7;
  --body: #dfe4ee;
  --muted: #8b93a5;
  --mono-muted: #7e8798;
  --tag-text: #c0c6d4;
  --callout-text: #c8cde0;
  --live: #62d99a;
  --beta: #e0be62;
  --dev: #e69752;
  --rule: rgba(255,255,255,0.08);
  --hairline-strong: rgba(255,255,255,0.16);
  --card-border: rgba(255,255,255,0.09);
  --tag-border: rgba(255,255,255,0.12);
  --node-border: rgba(255,255,255,0.14);
  --divider: rgba(255,255,255,0.06);
  --row-rule: rgba(255,255,255,0.07);
  --connector: rgba(255,255,255,0.25);
  --dashed: rgba(255,255,255,0.2);
  --font-sans: 'IBM Plex Sans', sans-serif;
  --font-mono: 'IBM Plex Mono', monospace;
}

/* Light theme: overrides only the values that flip. Set on the root element
   by the pre-paint init snippet / toggle button (data-theme="light"). */
:root[data-theme="light"] {
  color-scheme: light;
  /* Contrast is a property of the colour, not of the typeface, so neither of these
     can be fixed by setting them in a heavier font. Against white, the raw accent
     runs about 2.8:1: under the 4.5:1 text floor and under even the 3:1 floor for
     graphics, which is why a lit node's stroke and a travelling pulse were washing
     out on the light theme. Darkened, text lands near 5.9:1 and ink near 6.4:1, and
     both still read as the same purple. */
  --accent-text: color-mix(in srgb, var(--accent) 65%, black 35%);
  --accent-line: color-mix(in srgb, var(--accent) 60%, black 40%);
  --bg: #ffffff;
  --bg-panel: #f9fafd;
  --nav-bg: rgba(255,255,255,0.9);
  --card: #f9f9f9;
  --card-hover: #f5f7fb;
  --row-hover: rgba(22,27,46,0.05);
  --heading: #171a24;
  --body: #343a48;
  --muted: #5b6373;
  --mono-muted: #697083;
  --tag-text: #3d4453;
  --callout-text: #39404f;
  --live: #178f56;
  --beta: #9c7a12;
  --dev: #b25f1c;
  --rule: rgba(22,27,46,0.1);
  --hairline-strong: rgba(22,27,46,0.18);
  --card-border: #dcdcdc;
  --tag-border: rgba(22,27,46,0.16);
  --node-border: rgba(22,27,46,0.16);
  --divider: rgba(22,27,46,0.08);
  --row-rule: rgba(22,27,46,0.09);
  --connector: rgba(22,27,46,0.28);
  --dashed: rgba(22,27,46,0.22);
}

/* ===== RESET / BASE ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  min-height: 100vh;
  background: var(--bg);
  color: var(--body);
  font-family: var(--font-sans);
  -webkit-font-smoothing: antialiased;
}

/* Fade colours when the theme flips. Elements with their own transition
   (cards, links, scroll-fx targets) override this, so it only affects the
   theme change, not the reveal/hover motion. */
*, *::before, *::after {
  transition: background-color 0.25s ease, border-color 0.25s ease, color 0.2s ease;
}

::selection { background: rgba(139,147,232,0.35); }

.container {
  max-width: 1160px;
  margin: 0 auto;
  padding: 0 48px;
}

/* ===== NAV ===== */
.nav {
  position: sticky;
  top: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: var(--nav-h);
  border-bottom: 1px solid var(--rule);
  background: var(--nav-bg);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}
.nav-brand {
  font-weight: 700;
  font-size: 15.5px;
  color: var(--heading);
  letter-spacing: -0.01em;
}
.nav-right {
  display: flex;
  align-items: center;
  gap: 22px;
}
.nav-links {
  display: flex;
  gap: 28px;
  font-family: var(--font-mono);
  font-size: 13px;
}
.nav-links a {
  color: var(--mono-muted);
  text-decoration: none;
  transition: color 0.15s;
}
.nav-links a:hover { color: var(--heading); }
.nav-links .active { color: var(--heading); }

/* Theme toggle button (nav, top-right). Icons swap via data-theme in CSS. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--tag-border);
  border-radius: 7px;
  background: transparent;
  color: var(--mono-muted);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s;
}
.theme-toggle:hover {
  color: var(--heading);
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.theme-toggle svg { display: block; width: 16px; height: 16px; }
.theme-toggle .icon-moon { display: none; }
.theme-toggle .icon-sun { display: block; }
:root[data-theme="light"] .theme-toggle .icon-sun { display: none; }
:root[data-theme="light"] .theme-toggle .icon-moon { display: block; }

/* tl;dr journey (hub hero): the five-step story of a job, drawn as a closed
   cycle because step 05 really does feed step 01 (what lands is written back to
   memory, which is what the next ask starts from). Hand-authored inline SVG, not
   Mermaid: Mermaid has no radial layout, so a cycle comes out as a row with a
   lasso, and the hero cannot afford a CDN bundle that renders after first paint. */
.journey { margin-top: 32px; }

/* Radial: the default. The viewBox is cropped tight to the diagram's ink (not the
   ring's bounding box), so centring centres what you can actually see. Capping
   max-width at the viewBox width keeps it 1:1, so the labels hold a true 12px at
   every width it is drawn at. The labels are short and wrap to two lines that hug
   the ring, which keeps the crop narrow enough to stay readable down to a phone. */
.journey-radial {
  display: block;
  width: 100%;
  max-width: 480px;
  height: auto;
  margin: 0 auto;
  font-family: var(--font-mono);
}
/* jc-* paint classes are shared by every cycle diagram on the site (the hub
   hero, the workflow operating loop), so a ring only has to ship geometry. */
.jc-node {
  fill: var(--card);
  stroke: var(--tag-border);
  stroke-width: 1px;
}
/* Each node carries a glyph rather than its number; the number rides in front
   of the label instead, which reads in order and leaves the node to say what
   the step IS at a glance. */
.jc-icon {
  fill: none;
  stroke: var(--accent-text);
  stroke-width: 1.4px;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.jc-label { fill: var(--tag-text); font-size: 12px; }
.jc-num { fill: var(--accent-text); }

/* One tspan per letter, so a label can be lit the way an edge is crossed: the purple
   runs THROUGH the word instead of switching the whole of it on at once. js/cycle.js
   does the splitting and hands each letter its two delays, because only it knows where
   each letter was laid out.

   Two delays, not one, and they run in opposite directions. Coming on, the wave enters
   from the right and travels left, so it arrives at the word from the side the pulse
   arrived from. Going off, it leaves from the left and travels right, letting go in the
   order it took hold. The letters are lit for the whole beat in between: it is a wave
   passing through the word, not a shimmer.

   The delays live on the two states rather than in a keyframe, which is what lets one
   rule own the way in and the other own the way out. A letter that is never split (the
   svg is display:none under 640px, and the geometry cannot be measured) keeps the whole
   label rules above and lights all at once, exactly as it did before. */
.jc-ch {
  font-size: 12px;
  transition: fill 0.25s ease, font-size 0.25s ease;
  transition-delay: var(--jc-out, 0ms);
}
.jc-step:hover .jc-ch,
.jc-step.is-active .jc-ch {
  fill: var(--accent-text);
  transition-delay: var(--jc-in, 0ms);
}

/* Hovering a step lights the node and drops its detail card into the middle of
   the ring. The centre is the only place a card fits without padding the viewBox
   out to twice the diagram's size, and it is dead space otherwise.

   The cards are painted after every node (SVG has no z-index, so paint order IS
   the stacking order) and matched to their node with :has(), which is why the
   node groups and the cards both carry data-step. Cards are pointer-events:none
   so hovering one can never steal the hover from the node underneath.

   No tabindex on the groups: the SVG is role="img", which makes its contents
   presentational to a screen reader, and the same detail is in the numbered list
   below in full. The cards are decoration over content that is already there. */
/* 0.3s, not the snappier 0.15s a hover alone would want: the autoplay walk in
   js/cycle.js darkens a node, crosses the edge, and lights the next one, and at
   0.15s that handover reads as a flicker rather than a move. */
.jc-step .jc-node,
.jc-step .jc-label {
  transition: fill 0.3s ease, stroke 0.3s ease, font-size 0.3s ease,
              transform 0.3s ease;
}
/* The live node swells, and settles back when the walk moves on. transform-box pins
   the origin to the node's own bounding box, so a circle grows about its centre
   rather than about the origin of the whole diagram, which is where an SVG transform
   otherwise reaches for. */
.jc-node {
  transform-box: fill-box;
  transform-origin: center;
}
.jc-step:hover .jc-node,
.jc-step.is-active .jc-node {
  fill: var(--card-hover);
  stroke: var(--accent-line);
  transform: scale(1.12);
}
/* The live label (and the whole-label fallback for one js/cycle.js could not split)
   takes the ring's accent, so the walk is legible from the labels alone without having
   to track which circle is lit. It holds its resting size now: the colour and the node's
   own swell carry the emphasis, and a growing label pulled the wave's pinned letters out
   of place. */
.jc-step:hover .jc-label,
.jc-step.is-active .jc-label {
  fill: var(--accent-text);
}

/* 0.4s, under the 500ms js/cycle.js gives the edge crossing: the card of the step
   being left is fully gone before the card of the step being arrived at starts to
   come up, so the walk reads as out and then in rather than one card dissolving
   through the other. Raise it past 500ms and the two overlap again. */
.jc-tip {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}
/* Every card is the full width of its foreignObject, which is itself centred on
   the ring's centre. A shrink-to-fit card left-aligns instead, so a one-line
   step (04) sat off-centre inside the ring while a four-line step (02) filled
   it, and the walk between them looked like the card was sliding sideways. Only
   the height moves now, and it grows about the centre line. */
.jc-tip-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}
/* No card chrome: the copy sits straight on the page. The panel was drawing a box
   around the one part of the diagram that is already empty, and a bordered card
   floating inside a ring reads as a thing in its own right rather than as the
   caption to the node that is lit. Nothing is behind it to compete with, so it
   does not need to be lifted off anything. */
.jc-tip-card {
  width: calc(100% - 10px);
  font-family: var(--font-sans);
  font-size: 11.5px;
  line-height: 1.55;
  color: var(--tag-text);
}
/* The about page's graph is drawn at 444 units and rendered up to 495px, so everything
   inside it is magnified about 11%, and its type was reading a size larger than the same
   type on the rings, which are drawn 1:1. Every string in it is set down a notch, so the
   drawing keeps its size and the words inside it stop shouting. The card is the one that
   has to stay readable, so it gives up the least; the labels around the edges are
   annotations and can afford to be quiet. */
.hitl-svg .jc-tip-card { font-size: 10px; }
.hitl-svg .jc-tip-head { font-size: 10px; }
.hitl-svg .jc-box-text { font-size: 11px; }
.hitl-svg .jc-edge-label { font-size: 9.5px; }
.hitl-svg .jc-edge-label-cap { font-size: 9px; }
.hitl-svg .jc-zone-label { font-size: 9.5px; }
/* 30px narrower inside a ring, because a ring's dead space is a circle and a full
   width column runs its lines out to where the circle is already closing in on them.
   The card is centred by .jc-tip-wrap, so the 30px comes off both sides, 15 each. The
   about page's graph keeps the full width: its dead space is an actual rectangle. */
.cycle-svg .jc-tip-card { width: calc(100% - 40px); }
/* Accent, matching the node it belongs to while that node is the live one. With the
   card chrome gone there is no border left to tie the copy to the ring, so the
   heading is what does it: the same purple, in the same beat. */
.jc-tip-head {
  display: block;
  margin-bottom: 5px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.03em;
  color: var(--accent-text);
}
/* is-active is the autoplay walk (js/cycle.js): it paints exactly what a hover
   paints, on a five-second beat. There is no "hover me" hint any more because
   the walk is the invitation: a card is up in the centre within a beat of the
   ring coming into view, which says what a hover gets you better than a line of
   instructions did. */
.jc-tip.is-active { opacity: 1; }
/* Matched on the svg element rather than on a diagram's own class: jc-step and
   jc-tip only exist inside these diagrams, so the pairing is already specific, and
   every diagram that grows tips gets them without a third copy of this list. */
svg:has(.jc-step[data-step="1"]:hover) .jc-tip[data-step="1"],
svg:has(.jc-step[data-step="2"]:hover) .jc-tip[data-step="2"],
svg:has(.jc-step[data-step="3"]:hover) .jc-tip[data-step="3"],
svg:has(.jc-step[data-step="4"]:hover) .jc-tip[data-step="4"],
svg:has(.jc-step[data-step="5"]:hover) .jc-tip[data-step="5"],
svg:has(.jc-step[data-step="6"]:hover) .jc-tip[data-step="6"] {
  opacity: 1;
}
/* 0.6s: the drain back to grey, so the purple bleeds out of an edge rather than
   switching off. The wash IN is set on the lit state below, and is quicker. */
.jc-edge {
  fill: none;
  stroke: var(--connector);
  stroke-width: 1.5px;
  transition: stroke 0.6s ease;
}
/* The return edge carries a claim the five forward edges do not, so it is drawn
   differently: dashed, and labelled with its mechanism. On the hub hero, where
   nothing walks the ring, it is also accent and permanently flowing. */
.jc-return {
  fill: none;
  stroke: var(--accent-line);
  stroke-width: 1.5px;
  stroke-dasharray: 5 4;
  transition: stroke 0.6s ease;
}
/* On a ring the walk drives, the walk is what carries the accent, so a
   permanently purple, permanently flowing return edge would compete with it: it
   draws the eye to the one place nothing is happening. So it drops to grey and
   holds still until the walk crosses it. It keeps its dashes in both states,
   because the dashes are what say "this edge is a feedback loop, not a next
   step", and that is true whether or not the walk is on it.

   Keyed on the autoplay attribute rather than on a ring's class: it is the walk
   that makes the permanent flow redundant, so a ring that does not walk keeps
   the flow, and a ring that starts walking gives it up by saying so. */
[data-cycle-autoplay] .jc-return { stroke: var(--connector); }

/* The edge the walk is crossing right now. The purple TRAVELS: it leaves the node
   the walk is leaving and arrives at the node the walk is arriving at. Washing the
   whole edge purple at once said "this edge is involved"; the thing worth saying is
   "the work is moving, and this is where it has got to".

   It is a clone of the edge, laid over the top in the accent and drawn on from one
   end to the other. js/cycle.js builds the clones, so the markup stays one path per
   edge and no page can carry an edge with no pulse on it. The real edge underneath
   never changes: the grey line sits there the whole time with the purple running
   down it, which is the difference between current moving along a wire and the
   diagram redrawing itself as it goes. (Dashing the real edge was tried, and looked
   like the latter.)

   pathLength normalises every edge to a single unit, so one dash of length 1 covers
   any edge whatever its true length, and a crossing takes the same time whether the
   walk is stepping to the next node or running the long way home.

   It arrives and then TAILS OUT: the tail follows the head into the node it was
   heading for. It does not fade out where it lies, which left the purple dying in
   the middle of an edge, in a place the work never actually stopped at. The pulse
   goes somewhere and then it is gone, which is what the walk is claiming happened.

   One dash and one gap of a whole path each, so the offset drives both ends off the
   one number: from +1 the dash sits entirely before the path (nothing drawn), at 0
   it covers the path exactly (the head has arrived), and by -1 it has slid entirely
   past the end (the tail has followed it in). Head first, then tail, one linear
   sweep from +1 to -1.

   No arrowhead on the pulse: a marker paints at the path's end no matter where the
   dash currently is, so it would light at the far node the instant the pulse set off
   and still be sitting there after the tail had gone. The grey arrowhead underneath
   is the edge's own, and the node lighting is what says "arrived".

   COUPLED NUMBERS. The head reaches the far node at the animation's halfway point, so
   the crossing time is always half the duration: 0.5s of this 1s default, which is
   exactly CROSS_MS in js/cycle.js. The node lights and its label starts growing on
   that same frame, so the arrival hands straight over to the growth. The tail then
   has the balance.

   An edge that wants to be crossed at its own pace says so with data-cross, in ms,
   and js/cycle.js sets both sides of that from the one number: it waits that long
   before lighting the far node, and writes twice it onto the pulse as an inline
   animation-duration. So they cannot drift apart, and this rule only ever sets the
   default. The about page's return edge is the long way home and takes it slowly. */
.jc-edge-pulse {
  fill: none;
  stroke: var(--accent-line);
  stroke-width: 1.5px;
  stroke-dasharray: 1 1;
  stroke-dashoffset: 1;
}
.jc-edge-pulse.is-travelling {
  animation: jc-pulse 1s linear both;
}
@keyframes jc-pulse {
  0% { stroke-dashoffset: 1; }
  /* the head lands, and holds for a beat so the eye catches which way it came */
  50% { stroke-dashoffset: 0; }
  58% { stroke-dashoffset: 0; }
  /* the tail comes in after it, faster than it went out, so it reads as a whip
     rather than as the line being rubbed out from behind */
  100% { stroke-dashoffset: -1; }
}
.jc-return-label { fill: var(--mono-muted); font-size: 11px; }

@media screen and (prefers-reduced-motion: no-preference) {
  /* A ring that does not walk keeps its permanently flowing return edge. On one
     that walks, the pulse is what moves, and a second thing in motion competes. */
  .jc-return { animation: jc-flow 2.4s linear infinite; }
  [data-cycle-autoplay] .jc-return { animation: none; }
  /* Two full dash periods (5 + 4), so the loop is seamless. */
  @keyframes jc-flow {
    to { stroke-dashoffset: -18; }
  }
}

/* Linear fallback for very narrow screens. The radial now wraps its labels short and
   holds up down to a phone, so the strip only takes over below ~360px, where even the
   cropped ring runs the labels under 8px. display:none prunes whichever one is inactive
   from the a11y tree, so a screen reader is offered exactly one of them at any width.

   Each arrow is bound to the step that FOLLOWS it (.journey-link), so a wrap
   carries the arrow down with its step instead of stranding it at a line end. */
.journey-strip {
  display: none;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px 12px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--mono-muted);
}
.journey-link {
  display: inline-flex;
  align-items: center;
  gap: 12px;
}
.journey-step {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 8px 13px;
  border: 1px solid var(--tag-border);
  border-radius: 7px;
  background: var(--card);
}
.journey-num { color: var(--accent-text); }
.journey-arrow { color: var(--dashed); }
/* Stand-in for the return edge, wherever a ring is too wide to draw. Both are
   plain blocks inside, so the sentence wraps as normal inline text: as a flex
   box it refused to break and pushed the document into horizontal overflow. */
.journey-loop,
.cycle-note {
  padding: 8px 13px;
  border: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 7px;
  line-height: 1.7;
}
.journey-loop {
  display: block;
  flex-basis: 100%;  /* drops it onto its own row of the strip */
}
.journey-loop-icon,
.cycle-note-icon {
  margin-right: 9px;
  color: var(--accent-text);
}

@media (max-width: 360px) {
  .journey-radial { display: none; }
  .journey-strip { display: flex; }
}

/* ===== OPERATING LOOP CYCLE (workflow page) =====
   Same six steps as the numbered list beneath it, but a list cannot say the
   thing that matters most about them: 06 feeds 01. The ring carries the shape,
   the list carries the detail, which is why the ring labels stay bare names. */
/* max-width tracks the viewBox width (516 of ring plus 16 either side for a live
   label at 13.5px), which holds the ring 1:1 and the labels at a true 12px. */
.cycle-svg {
  display: block;
  width: 100%;
  max-width: 548px;
  height: auto;
  margin: 28px auto 0;
  font-family: var(--font-mono);
}
.cycle-note {
  display: none;
  margin-top: 18px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--mono-muted);
}

/* The ring says all six steps in its own centre now, so the list underneath repeats
   them to anyone who can see the ring. Hidden from the eye, NOT from the page: it is
   still the only place the detail exists for a reader who cannot use the ring, and
   display:none would take it away from them too. So it is clipped to nothing and left
   in the accessibility tree, and the 640px rule below hands it straight back the
   moment the ring is dropped. */
.loop-steps {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The rings now stay down to a phone: the radial wraps its labels short and the workflow
   ring's are already one word each, so a ring is only dropped for its numbered list below
   ~360px, where the labels run under 8px. The about page's branch loop stays at every
   width too and drops its text stand-in. */
@media (max-width: 360px) {
  .cycle-svg { display: none; }
  .cycle-note { display: block; }
  .card-stack > .cycle-note { display: none; }  /* index shows the loop, so no text stand-in */
  /* Every property of the clip, put back, and the .card and .mt-28 it sits on restated
     because the clip had to null them out: with the ring gone, this IS the content. */
  .loop-steps {
    position: static;
    width: auto;
    height: auto;
    padding: 22px;
    margin: 28px 0 0;
    border: 1px solid var(--card-border);
    overflow: visible;
    clip-path: none;
    white-space: normal;
  }
}

/* ===== HUMAN-IN-THE-LOOP GRAPH (about page) =====
   Not a third ring, deliberately. A ring cannot draw a branch, and the one thing
   this diagram exists to say is that correction is conditional: the edge into it
   is the edge that may not be taken. So it is a graph, and it reuses the ring's
   jc-* paint classes and the js/cycle.js walk unchanged. Only the two ideas a ring
   does not have are new here: a boundary, and an optional edge.

   It also sits in the narrow right-hand column of the split, which a ring would
   not survive: at ~470px a ring's outboard labels fall under 11px. Boxes carry
   their label inside instead, which holds the type at full size and buys the room
   for the branch to sit beside the spine. */
/* The sizing lives on the figure rather than on the svg, so the player below can be
   positioned against the diagram's own box: it is the svg's only sibling, and the
   two have to agree on where the right-hand edge is. */
.hitl-figure {
  position: relative;
  width: 100%;
  max-width: 495px;
  margin: 0 auto;
}
.hitl-svg {
  display: block;
  width: 100%;
  height: auto;
  font-family: var(--font-mono);
}

/* Transport controls. Percentages, not pixels: they resolve against the figure, which
   is the svg, so the player stays welded to the diagram's coordinates at every width.
   top is the Me node's centre line (viewBox y 34 of 420) and right is the handoff
   zone's right edge (viewBox x 429 of 444), so the player sits level with the node it
   hands control back to and flush with the diagram it drives. */
.cycle-controls {
  position: absolute;
  top: 8.1%;
  right: 3.4%;
  transform: translateY(-50%);
  display: flex;
  gap: 5px;
}
.cycle-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 1px solid var(--tag-border);
  border-radius: 6px;
  background: var(--card);
  color: var(--mono-muted);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.cycle-btn:hover {
  color: var(--accent-text);
  border-color: var(--accent);
  background: var(--card-hover);
}
.cycle-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* One glyph or the other, never both. Playing is the default, so the button offers
   the pause, and only a paused player offers the play. */
.cyc-play { display: none; }
.cycle-controls.is-paused .cyc-play { display: block; }
.cycle-controls.is-paused .cyc-pause { display: none; }
/* Step dots, filled in by js/cycle.js when the player carries a [data-cycle-dots]
   strip: a direct way to any step, since hovering the screenshot tours' thin-stroke
   hotspots is unreliable. Sat left of the buttons, one dot per walk entry. */
.cycle-dots { display: flex; align-items: center; gap: 7px; margin-right: 8px; }
.cycle-dots:empty { display: none; }
.cycle-dot {
  width: 9px;
  height: 9px;
  padding: 0;
  border: 1px solid var(--mono-muted);
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.cycle-dot:hover { border-color: var(--accent-line); transform: scale(1.3); }
.cycle-dot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.cycle-dot.is-active { background: var(--accent-line); border-color: var(--accent-line); }
/* is-invited: js/cycle.js adds this the moment a data-cycle-hover="hold" stage is
   entered, so a reader whose hover just paused the walk (rather than jumping it, the
   old per-node behaviour that got too easy to trip on a screenshot's scattered
   hotspots) is shown where navigation actually lives now. Border colour only: the
   scale pulse this used to run moved the buttons under the cursor mid-click. */
.cycle-controls.is-invited .cycle-dot,
.cycle-controls.is-invited .cycle-btn { border-color: var(--accent-line); }

/* A player for a walk that never walks is a lie: js/cycle.js does not run under
   reduced motion, so the buttons would sit there doing nothing. */
@media (prefers-reduced-motion: reduce) {
  .cycle-controls { display: none; }
}
.jc-box {
  fill: var(--card);
  stroke: var(--tag-border);
  stroke-width: 1px;
}
.jc-box-text { fill: var(--tag-text); font-size: 12px; }
/* The step that may be skipped is drawn as the dashed edge that reaches it, and as
   itself: same dashes on the node, so it reads as provisional whether the eye lands
   on the box or on the arrow into it. It keeps them when lit, since a correction
   being underway does not make it any less conditional. */
.jc-box-cond { stroke-dasharray: 4 3; }
/* No font-size growth on the live step, unlike the rings: their labels sit in open
   space and can grow into it, but these sit inside a fixed box and would reflow
   against its edges. Fill and stroke carry the walk instead. */
.jc-step .jc-box {
  transform-box: fill-box;
  transform-origin: center;
  transition: fill 0.3s ease, stroke 0.3s ease, transform 0.3s ease;
}
.jc-step .jc-box-text { transition: fill 0.3s ease; }
/* The letter-split (js/cycle.js) hands every label the rings' .jc-ch growth below, but
   a box is a fixed container and its letters are pinned to the centres they were measured
   at while resting: grow them and they crowd their neighbours rather than spreading, since
   nothing widens the gaps to match. So the about-page letters hold their size, which is
   what the "no font-size growth" note above always meant, and the box scale carries the
   emphasis instead. */
.hitl-svg .jc-step:hover .jc-ch,
.hitl-svg .jc-step.is-active .jc-ch { font-size: 12px; }
/* The box scale is the whole of the emphasis now the letters hold their size, so it grows
   a touch more than it did when they were also swelling. Still under the rings' 1.12: a
   box is a good deal bigger than a ring node, so the same scale is a larger movement. */
.jc-step:hover .jc-box,
.jc-step.is-active .jc-box {
  fill: var(--card-hover);
  stroke: var(--accent-line);
  transform: scale(1.09);
}
.jc-step:hover .jc-box-text,
.jc-step.is-active .jc-box-text { fill: var(--accent-text); }

/* The handoff boundary. Everything inside it runs without me; the two edges that
   cross it are the whole claim of the diagram, which is why the zone is drawn at
   all rather than just implied by the arrows. */
.jc-zone {
  fill: none;
  stroke: color-mix(in srgb, var(--accent-line) 28%, transparent);
  stroke-width: 1px;
  stroke-dasharray: 6 5;
}
.jc-zone-label {
  fill: var(--mono-muted);
  font-size: 11px;
  letter-spacing: 0.06em;
}
.jc-edge-label { fill: var(--mono-muted); font-size: 11px; }
/* The failure branch is the site's warn colour, the same one the negative cards
   take, so "this is the unhappy path" is said in colour and not only in words. */
.jc-edge-label-cond { fill: var(--dev); }
/* The bound on the correction round trip, sitting inside the lens the two edges
   enclose. Smaller and quieter than an edge label: it is a footnote on the loop, not
   a name for either edge. */
.jc-edge-label-cap {
  font-size: 10px;
  letter-spacing: 0.04em;
  opacity: 0.85;
}

/* An edge that is only sometimes taken, drawn as such. Same weight and same walk
   behaviour as a forward edge (the .jc-edge.is-travelling rule below picks it up
   through the shared class); the dashes are the only difference, and they carry
   the conditional. */
.jc-branch {
  stroke-dasharray: 5 4;
}

/* Introduces the graph below it, so it reads first and the diagram is what the words
   resolve into. Sits in the .card-stack flex column, so the gap below is already there.
   No top margin, unlike the .card-offset the other splits give their right column: this
   column opens with a caption rather than a card, and it should start level with the
   prose it sits beside. */
.hitl-caption {
  margin-top: 0;
  margin-bottom: 0px;
}
/* A size under the prose it sits beside, all three parts of it: this is a caption on a
   figure, not a second column of argument, and reading as one was making the page look
   like it was making the same point twice at the same volume. */
.hitl-caption-head {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--accent-text);
  letter-spacing: 0.04em;
}
.hitl-caption-body {
  margin-top: 8px;
  font-size: 15px;
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}
/* The 8px above is the gap under the heading, which is too tight to also serve as
   the gap between two paragraphs of it. */
.hitl-caption-body + .hitl-caption-body { margin-top: 12px; }
.hitl-caption-link {
  display: inline-block;
  margin-top: 10px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--accent-text);
  text-decoration: none;
}
.hitl-caption-link:hover { text-decoration: underline; }
/* The figure is the only card-stack child that brings its own spacing: the drawing sits
   18px inside the top of its own viewBox and 20px above the bottom, so the stack's 20px
   gap was doubling up into ~38px of air above it while the drawing's foot hung ~20px
   below the callout opposite. Most of that gap is the SVG's job, so only 10px of it is
   the stack's: enough to sit the drawing just under the callout's foot without the
   caption crowding it. */
.split-why:has(.hitl-figure) .card-stack { gap: 10px; }

/* ===== SUBNAV ===== */
.subnav {
  position: sticky;
  top: var(--nav-h);
  z-index: 99;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 20px;
  padding: 12px 0;
  border-bottom: 1px solid var(--rule);
  font-family: var(--font-mono);
  font-size: 11.5px;
  background: var(--nav-bg);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}
.subnav-label { color: var(--accent-text); }
.subnav a {
  color: var(--mono-muted);
  text-decoration: none;
  transition: color 0.15s;
}
.subnav a:hover { color: var(--heading); }
.subnav .active { color: var(--heading); }
a.subnav-label { text-decoration: none; transition: color 0.15s; }
a.subnav-label:hover { color: var(--heading); }
.subnav-divider {
  width: 1px;
  height: 14px;
  background: var(--node-border);
}
/* On-this-page strip: a section index for long merged pages. It pins as one
   block with the subnav so both tiers stay visible while scrolling. The stack
   owns the pinning and its children are in flow, so no per-tier height offset
   has to stay in sync with a wrapping subnav. */
.subnav-stack {
  position: sticky;
  top: var(--nav-h);
  z-index: 99;
}
.subnav-stack > .subnav {
  position: static;
  top: auto;
  z-index: auto;
}
.toc-strip { position: static; }

/* Anchor targets land clear of the pinned chrome below .nav: one subnav
   normally, two when the on-this-page strip is present. */
:root:has(.subnav-stack) { --anchor-offset: 104px; }
.label-bar { scroll-margin-top: calc(var(--nav-h) + var(--anchor-offset)); }
/* Sections that carry their own anchor (how-it-works dropped its per-section
   label-bars) need the same landing offset as the bars they replaced. */
.section[id] { scroll-margin-top: calc(var(--nav-h) + var(--anchor-offset)); }

/* ===== HERO ===== */
.hero { padding: 30px 0; }
.kicker {
  font-family: var(--font-mono);
  font-size: 13px;
  letter-spacing: 0.05em;
  color: var(--accent-text);
}
.kicker-row {
  display: flex;
  align-items: center;
  gap: 14px;
}
.hero-title {
  font-weight: 600;
  line-height: 1.04;
  letter-spacing: -0.03em;
  color: var(--heading);
}
.page-home .hero-title {
  font-size: clamp(44px, 5.5vw, 60px);
  max-width: 880px;
  margin-top: 26px;
  text-wrap: balance;
}
.page-project .hero-title {
  font-size: clamp(44px, 5vw, 60px);
  margin-top: 22px;
}
/* the rename mark, only on the AgentOS-to-Fulltrace hub page: quiet enough to sit
   under the title without competing with the tagline below it */
.hero-standfirst {
  font-size: 17.5px;
  line-height: 1.65;
  color: var(--accent-text);
  max-width: 600px;
  margin-top: 26px;
  text-wrap: pretty;
}
.hero-tagline {
  font-size: 18px;
  font-weight: 500;
  color: var(--accent-text);
  margin-top: 10px;
}
.hero-desc {
  font-size: 15.5px;
  line-height: 1.7;
  color: var(--muted);
  max-width: 720px;
  margin-top: 20px;
  text-wrap: pretty;
}
.hero-stats {
  display: flex;
  flex-wrap: wrap;
  gap: 28px;
  margin-top: 20px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--mono-muted);
}
.page-project .hero-stats { margin-top: 32px; }
.stat-num { color: var(--accent-text); }

/* ===== SECTION LABEL BAR ===== */
.label-bar {
  display: flex;
  align-items: center;
  gap: 18px;
  padding: 10px 0;
  font-family: var(--font-mono);
}
.label-bar-name {
  font-size: 12.5px;
  letter-spacing: 0.08em;
  color: var(--accent-text);
}
.label-bar-line {
  flex: 1;
  height: 1px;
  background: var(--divider);
}
.label-bar-count {
  font-size: 11.5px;
  color: var(--accent-text);
}

/* ===== CHAPTER BARS ===== */
/* A tier above .label-bar: chapter dividers that group the numbered sections
   on long pages. Same grid as label-bar so the mono rhythm holds; rank comes
   from extra air, a brighter name, and an accent-tinted rule. The one-line
   lede under the bar lets a reader decide to skip the whole chapter. */
.chapter-bar { padding: 12px 0 8px 0; }
.section + .chapter-bar { margin-top: 46px; }
.chapter-bar .label-bar-name {
  font-size: 13.5px;
  letter-spacing: 0.1em;
  color: var(--heading);
}
.chapter-bar .label-bar-line {
  background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.chapter-bar .label-bar-count { color: var(--mono-muted); }
.chapter-lede {
  margin: 0 0 6px 0;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  color: var(--mono-muted);
}

/* ===== TECH DETAIL EXPANDER ===== */
/* Inline disclosure for deep detail that would crowd the narrative. Native
   <details>: no-JS still works and find-in-page can reach the content; the
   page's beforeprint hook opens them for print. */
.tech-detail {
  margin-top: 24px;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: 8px;
}
.tech-detail + .tech-detail { margin-top: 12px; }
.tech-detail > summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 13px 18px;
  font-family: var(--font-mono);
  font-size: 12.5px;
  letter-spacing: 0.08em;
  color: var(--accent-text);
}
.tech-detail > summary::-webkit-details-marker { display: none; }
.tech-detail > summary::before {
  content: '[+]';
  color: var(--mono-muted);
  flex-shrink: 0;
}
.tech-detail[open] > summary::before { content: '[-]'; }
.tech-detail > summary:hover { color: var(--heading); }
.tech-detail > summary:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}
.tech-detail-hint {
  margin-left: auto;
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--mono-muted);
  text-align: right;
}
.tech-detail-body { padding: 2px 18px 18px 18px; }
.tech-detail-body > :first-child { margin-top: 0; }
.tech-detail-body > .prose-p { font-size: 14.5px; }

/* Expand-all control riding the on-this-page strip. */
.toc-expand {
  margin-left: auto;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 11.5px;
  letter-spacing: 0.05em;
  color: var(--mono-muted);
  transition: color 0.15s;
}
.toc-expand:hover { color: var(--heading); }

/* ===== SECTIONS ===== */
/* 26px top, not 10: every section leads its content the same distance below the
   divider as the about page does. There the first line is a .prose-p carrying its
   own 16px top margin (10 + 16); heading-led sections had only the 10, so they read
   tighter. The gap lives on the section now, so both kinds match. */
.section { padding: 26px 0 20px 0; }
/* ...which means whatever leads the section (a paragraph or lede carrying its own
   top margin) would now stack on top of that. Drop the leading element's own margin
   so every section, heading-led or prose-led, starts the same distance below the
   divider. Splits nest their lead a level down; there only the text column is zeroed,
   so the right column keeps its deliberate card offset. */
.section > :first-child,
.split-why > div > .prose-p:first-child,
.split-log > div > .prose-p:first-child { margin-top: 0; }
/* Mid-page dividers sit between two sections; the first divider gets its air from
   the hero's 30px foot above it. Give the rest the same, so the whole page keeps one
   rhythm. The about page's single divider follows the hero, not a section, so it is
   untouched. */
.section + .label-bar { margin-top: 10px; }
.section-h2 {
  font-size: 28px;
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--heading);
}
.section-lede {
  font-size: 15px;
  line-height: 1.7;
  color: var(--muted);
  max-width: 720px;
  margin-top: 14px;
  text-wrap: pretty;
}
.prose-p {
  font-size: 15px;
  line-height: 1.7;
  color: var(--muted);
  margin-top: 16px;
  text-wrap: pretty;
}
.split-why {
  display: grid;
  grid-template-columns: 1.2fr 1fr;
  gap: 48px;
  align-items: start;
}
/* The one split whose right column is a drawing rather than a card: it is scaled to the
   column, so every pixel the prose gives up is a pixel the diagram gets. Even halves here
   and nowhere else, since the other seven split-why sections have prose worth the wider
   half. */
.split-why:has(.hitl-figure) {
  grid-template-columns: 1fr 1fr;
  gap: 80px;
}
.split-log {
  display: grid;
  grid-template-columns: 1fr 1.1fr;
  gap: 48px;
  align-items: start;
}
/* Architecture map band: prose left, a wider diagram right. The text column stretches to
   the diagram's height and distributes top-to-bottom, so the caption pins to the foot of
   the graphic rather than stranding empty space under short prose. */
.split-diagram {
  display: grid;
  grid-template-columns: 1fr 1.25fr;
  gap: 48px;
  align-items: stretch;
}
.split-diagram > .split-text {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 16px;
}
/* Stretch the diagram container to the text column's height so its top lines up with the
   heading and its foot with the last legend line; centre the diagram within any slack. */
.split-diagram > .panel { align-self: stretch; margin-top: 0; display: flex; flex-direction: column; justify-content: center; }
/* Fill the column so the compacted diagram scales up to the available width instead of
   leaving side margins at native size; this is what makes the nodes and labels read larger. */
.split-diagram .panel .mermaid svg { width: 90%; }
/* Legend words tinted to their matching diagram node colours (model/hub/projects). */
.split-diagram .k-model { color: #8b93e8; font-weight: 600; }
.split-diagram .k-entry { color: #e0be62; font-weight: 600; }
.split-diagram .k-code { color: #62d99a; font-weight: 600; }
.split-diagram .section-h2 { margin-top: 0; }
.split-diagram .note { margin: 0; }
/* Dark theme: the legend sits on the page background at caption size, where --mono-muted
   reads too dim on a phone and the linking words between the coloured keywords wash out.
   Lift it to the callout-body tone. Light mode already clears the contrast floor. */
:root:not([data-theme="light"]) .split-diagram .note { color: var(--callout-text); }
.split-diagram .legend { display: flex; flex-direction: column; gap: 16px; }
.callout {
  margin-top: 22px;
  padding: 18px 20px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--accent) 7%, transparent);
  border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
  font-size: 15px;
  line-height: 1.6;
  color: var(--callout-text);
}

/* ===== GRIDS ===== */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.grid-5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
/* Compact card copy for the 5-across portfolio grid: smaller filenames and blurbs
   so ten cards read comfortably in two rows. */
.grid-5 .card-sm { padding: 15px; }
.grid-5 .code-accent { font-size: 11px; }
.grid-5 .card-desc-sm { font-size: 11.5px; line-height: 1.5; margin-top: 5px; }

/* ===== CARDS ===== */
.card {
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 22px;
}
.card-sm { padding: 20px; }
.card-link {
  display: block;
  text-decoration: none;
  color: inherit;
  transition: border-color 0.15s, background 0.15s;
}
.card-link:hover {
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  background: var(--card-hover);
}
.card-kicker {
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--mono-muted);
}
.card-title, .card-title-sm {
  font-weight: 600;
  color: var(--heading);
}
.card-title { font-size: 17px; margin-top: 10px; }
.card-title-sm { font-size: 15.5px; margin-top: 8px; }
.card-title-mono {
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--accent-text);
  margin-top: 10px;
}
.card-desc, .card-desc-sm { color: var(--muted); }
.card-desc { font-size: 14px; line-height: 1.6; margin-top: 8px; }
.card-desc-sm { font-size: 13.5px; line-height: 1.55; margin-top: 6px; }
.card-cta, .card-cta-sm {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--accent-text);
}
.card-cta { margin-top: 14px; }
.card-cta-sm { margin-top: 12px; }
.card-note {
  font-size: 14px;
  line-height: 1.65;
  color: var(--muted);
  margin-top: 10px;
}
.card-neg { border-color: color-mix(in srgb, var(--dev) 40%, transparent); }
.card-neg .card-kicker { color: var(--dev); }
.card-pos { border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.card-pos .card-kicker { color: var(--accent-text); }
.card-stack {
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.card-offset { margin-top: 15px; }
.num-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 14px;
}
.num-list > div {
  display: flex;
  flex-wrap: wrap;
  gap: 2px 12px;
  font-size: 13.5px;
  color: var(--tag-text);
}
.num-list > div > .code-accent {
  flex-shrink: 0;
  white-space: nowrap;
}
.num-list .num {
  font-family: var(--font-mono);
  font-size: 11.5px;
  color: var(--mono-muted);
}

/* ===== BADGES / TAGS / INLINE CODE ===== */
.badge {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  padding: 2px 8px;
  border-radius: 3px;
  border: 1px solid;
}
.badge-live { color: var(--live); border-color: color-mix(in srgb, var(--live) 45%, transparent); }
.badge-beta { color: var(--beta); border-color: color-mix(in srgb, var(--beta) 45%, transparent); }
.badge-dev { color: var(--dev); border-color: color-mix(in srgb, var(--dev) 45%, transparent); }
.tag-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  font-family: var(--font-mono);
}
.tag {
  font-size: 11px;
  color: var(--tag-text);
  border: 1px solid var(--tag-border);
  padding: 3px 9px;
  border-radius: 4px;
}
.code-accent {
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--accent-text);
}

/* ===== HOMEPAGE WORK INDEX ===== */
.work-row {
  display: grid;
  grid-template-columns: 88px 1fr auto;
  gap: 36px;
  padding: 25px;
  margin: 10px 0;
  border-radius: 8px;
  background: var(--card);
  border: 1px solid var(--card-border);
  text-decoration: none;
  color: inherit;
  transition: background 0.15s, border-color 0.15s;
}
.work-row:hover { background: var(--card-hover); }
.work-index {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--mono-muted);
}
.work-row .badge { margin-top: 12px; }
.work-title {
  font-size: 24px;
  font-weight: 600;
  letter-spacing: -0.015em;
  color: var(--heading);
}
.work-sub {
  font-size: 15px;
  font-weight: 500;
  color: var(--accent-text);
  margin-top: 5px;
}
.work-desc {
  font-size: 15px;
  line-height: 1.6;
  color: var(--muted);
  max-width: 620px;
  margin-top: 14px;
  text-wrap: pretty;
}
.work-row .tag-row { margin-top: 18px; }
.details-btn {
  align-self: start;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--accent-text);
  white-space: nowrap;
  border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  padding: 7px 14px;
  border-radius: 5px;
  transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.details-btn:hover {
  border-color: var(--accent);
  color: var(--accent-text);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}

/* ===== BUILD LOG TIMELINE ===== */
.log-row {
  display: grid;
  grid-template-columns: 96px 1fr;
  gap: 36px;
  padding: 22px 0;
  border-top: 1px solid var(--divider);
}
.log-row:first-child { border-top: none; }
.log-date {
  font-family: var(--font-mono);
  font-size: 11.5px;
  letter-spacing: 0.06em;
  color: var(--accent-text);
  white-space: nowrap;
  padding-top: 2px;
}

/* ===== SYSTEM MAP ===== */
.sysmap {
  margin-top: 28px;
  background: var(--bg-panel);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 32px;
}
.sysmap-label { margin-bottom: 24px; }
.sysmap-inner {
  max-width: 760px;
  margin: 0 auto;
}
.map-row-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.map-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.map-node {
  border: 1px solid var(--node-border);
  border-radius: 6px;
  padding: 12px 14px;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--tag-text);
}
.map-node-lg { padding: 14px 16px; }
.map-node-dashed {
  border: 1px dashed var(--dashed);
  padding: 12px 16px;
}
.map-sub {
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--mono-muted);
  margin-top: 4px;
}
.map-dim { color: var(--mono-muted); }
.map-connector { display: flex; justify-content: center; }
.map-line {
  display: block;
  width: 1px;
  height: 22px;
  background: var(--connector);
}
.map-gateway {
  border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
  background: color-mix(in srgb, var(--accent) 6%, transparent);
  border-radius: 6px;
  padding: 14px 18px;
  text-align: center;
}
.map-gateway-title {
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--accent-text);
}

/* ===== SYSTEM MAP: VERTICAL LAYER STACK =====
   A second .sysmap used for the "how it all connects" summary. It reuses
   .map-node / .map-line so it inherits the cascade reveal (setupSysmap sets
   --fx-i, the .fx-map-on rules light it), but stacks four layers top to bottom
   with a labelled flow connector between each, rather than the top map's
   fan-in shape. Lives in the split-log right column, so margin-top is dropped
   to sit level with the heading in the left column. */
.sysmap-stack {
  margin-top: 0;
  padding: 24px;
}
.sysmap-stack .sysmap-inner { max-width: none; }
.layer-node {
  text-align: left;
  padding: 14px 16px;
}
.layer-role {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--mono-muted);
}
.layer-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 14px;
  margin-top: 7px;
}
.layer-path {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--accent-text);
}
.layer-meta {
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--mono-muted);
}
.layer-detail {
  font-size: 12px;
  line-height: 1.5;
  color: var(--tag-text);
  margin-top: 8px;
}
.layer-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
  margin-top: 11px;
}
.layer-chip {
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--tag-text);
  border: 1px solid var(--tag-border);
  border-radius: 4px;
  padding: 3px 9px;
}
.layer-chip .layer-arrow { color: var(--accent-line); padding: 0 1px; }
/* the access layer is the hub: echo the top map's accented gateway */
.layer-node-accent {
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
  background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.layer-node-accent .layer-role { color: var(--accent-text); }
.layer-link {
  display: grid;
  justify-items: center;
  gap: 5px;
  padding: 3px 0;
}
.layer-link .map-line { height: 18px; }
.layer-arrow-down {
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--connector);
  margin-top: -2px;
}
.layer-flow {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--mono-muted);
}

/* ===== PANELS / FIGURES ===== */
.panel {
  margin-top: 28px;
  background: var(--bg-panel);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 24px;
}
.figure {
  margin-top: 28px;
  background: var(--bg-panel);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  overflow: hidden;
}
.figure img { display: block; width: 100%; height: auto; }
.figure figcaption {
  padding: 10px 14px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.03em;
  color: var(--mono-muted);
  border-top: 1px solid var(--card-border);
}

/* ===== DEMO PLAYER =====
   The dashboard tour player (SCREENSHOT WALKTHROUGH, TOUR CATALOGUE, DEMO CURTAIN,
   HELD/PAUSED CUE, DASHBOARD WALKTHROUGH, WORKFLOW DETAIL FINALE, INSIDE A RUN and
   the sidebar mock) moved out to css/demo.css when the Fulltrace Website build of the
   player was brought back. Only fulltrace-dashboard.html loads it.

   The shared .jc-tip and .cycle-* base rules above stay here: the loop and graph
   diagrams on the other pages still use them, and demo.css only overrides the parts
   the framed window changes. */


.panel > .mermaid {
  display: flex;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--mono-muted);
}
/* iOS Safari undersizes the foreignObject that mermaid wraps each HTML label in, then
   clips the label to that box, so the shapes paint but the text vanishes. Letting the
   box overflow lets the label paint regardless of the miscomputed size. WebKit 165516. */
.mermaid foreignObject,
.mermaid foreignObject > div { overflow: visible; }
/* Scale the generated svg to the phone. height:auto resolves because mermaid emits a
   real width/height; min-width:0 stops it forcing horizontal page scroll as a flex item. */
.mermaid svg { max-width: 100%; height: auto; min-width: 0; }
/* Equal-size decision diamonds: mermaid sizes the rhombus to the label box.
   Fixed narrow width wraps long labels instead of growing the diamond. */
.mm-decision { display: inline-block; width: 96px; white-space: normal; }
.mm-decision-sm { display: inline-block; min-width: 80px; }
.note {
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--mono-muted);
  margin-top: 14px;
}

/* ===== BUILD LOG TREE ===== */
.tree {
  background: var(--bg-panel);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 24px;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 1.9;
  color: var(--tag-text);
  overflow-x: auto;
}
.tree .dir { color: var(--accent-text); }
.tree .dim { color: var(--mono-muted); }
.tree .indent { padding-left: 16px; }
.tree .indent-2 { padding-left: 32px; }
.tree .indent-3 { padding-left: 48px; }
.tree .gap { height: 10px; }
.text-link {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--accent-text);
  text-decoration: none;
  margin-top: 18px;
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  padding-bottom: 2px;
}
.inline-link {
  color: var(--accent-text);
  text-decoration: none;
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
}
/* A text-link inside a flowing paragraph is an inline link: the standalone
   metrics (inline-block + margin-top, meant for links standing under prose)
   otherwise inflate that line's box height. */
p .text-link {
  display: inline;
  margin-top: 0;
}

/* ===== TABLES ===== */
.bp-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 24px;
}
.bp-table th {
  text-align: left;
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--mono-muted);
  padding: 8px 12px;
  border-bottom: 1px solid var(--rule);
}
.bp-table td {
  padding: 12px;
  border-bottom: 1px solid var(--divider);
  color: var(--muted);
  font-size: 13.5px;
  line-height: 1.6;
  vertical-align: top;
}
.bp-table td:first-child {
  font-family: var(--font-mono);
  font-size: 11.5px;
  color: var(--accent-text);
  white-space: nowrap;
}
.bp-table tr:last-child td { border-bottom: none; }
/* Opt-in sticky header for long registers: pins below the nav and a
   single-line subnav. Collapse mode paints cell borders in the table layer,
   where they would not travel with the pinned cells, so this variant uses the
   separate border model. The 38px undershoots the ~40px subnav so the header
   tucks under it rather than opening a gap rows could scroll through. */
.bp-table-sticky {
  border-collapse: separate;
  border-spacing: 0;
}
.bp-table-sticky th {
  position: sticky;
  top: calc(var(--nav-h) + 38px);
  z-index: 2;
  background: var(--bg);
}

/* ===== CTA BANNER ===== */
.cta-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 32px;
  margin-top: 28px;
  padding: 28px 32px;
  border: 1px solid var(--card-border);
  border-radius: 8px;
  background: var(--card);
}
.cta-title {
  font-size: 19px;
  font-weight: 600;
  color: var(--heading);
}
.cta-sub {
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--muted);
  margin-top: 6px;
  max-width: 560px;
}
.btn-solid {
  flex-shrink: 0;
  background: var(--accent);
  color: #0a0c14;
  font-weight: 600;
  font-size: 13.5px;
  padding: 11px 22px;
  border-radius: 5px;
  text-decoration: none;
}

/* ===== RESOURCES ===== */
.resources-intro {
  font-size: 15px;
  line-height: 1.7;
  color: var(--muted);
  max-width: 600px;
  text-wrap: pretty;
}

/* ===== CONTACT FORM ===== */
/* The grid item holding the form. min-width is the whole reason it has a class:
   a grid item's automatic minimum is its min-content width, so the fixed 300px
   Turnstile box deep inside it was setting a floor for the whole track and the
   column refused to go narrower than the widget on a small phone. Zero here lets
   the track be exactly the 1fr it asks for, and the widget is fitted to it by
   .form-turnstile below. */
.form-col { min-width: 0; }
/* Sits in the left column of a .split-why, so it takes the column's width rather
   than setting one of its own. */
.form-card {
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 26px;
}
.form {
  display: flex;
  flex-direction: column;
  gap: 18px;
}
/* The card keeps its border after a successful send, so the confirmation lands
   inside the same frame the form was in rather than on bare page. */
.form[hidden] { display: none; }
.form-row {
  display: flex;
  flex-direction: column;
  gap: 7px;
}
.form-label {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--heading);
}
.form-req {
  margin-left: 8px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-weight: 400;
  color: var(--mono-muted);
}
/* Two things a form control will not do on its own. It does not inherit font, so
   without the family and size named here the inputs come out in the browser's
   default sans at 13.33px. And a textarea's intrinsic width comes from its `cols`
   attribute, 20 characters by default, which as a flex item becomes a
   content-based minimum its ancestors have to honour; min-width:0 keeps that off
   the card's sizing the way .form-col does for the track. */
.form-input {
  width: 100%;
  min-width: 0;
  padding: 11px 13px;
  background: var(--bg-panel);
  border: 1px solid var(--tag-border);
  border-radius: 6px;
  font-family: var(--font-sans);
  font-size: 14.5px;
  line-height: 1.5;
  color: var(--body);
  caret-color: var(--accent-text);
}
.form-input:hover { border-color: var(--hairline-strong); }
.form-input:focus {
  outline: none;
  border-color: var(--accent);
}
.form-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.form-input::placeholder { color: var(--mono-muted); }
.form-input--area {
  min-height: 150px;
  resize: vertical;
}
/* Not decoration: an inset fill is the only way to repaint the browser's autofill
   background, which otherwise lands pale yellow in both themes. */
.form-input:-webkit-autofill,
.form-input:-webkit-autofill:hover,
.form-input:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--body);
  -webkit-box-shadow: 0 0 0 100px var(--bg-panel) inset;
}
/* Honeypot. Off-screen rather than display:none, which the cruder bots skip. */
.form-trap {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
/* min-height reserves the row so the card does not jump when the widget finishes
   loading. overflow is what keeps a phone off a horizontal scroll: Turnstile sets
   a hard 300px width on its render target through an id selector, which no class
   rule here can outrank, and that box is wider than the card below 360px. Making
   this wrapper a scroll container gives it an automatic minimum size of zero, so
   the card sizes to the viewport and the fixed box overflows into the clip
   instead of dragging the page sideways. */
.form-turnstile {
  min-height: 65px;
  min-width: 0;
  overflow: hidden;
}
/* Scaling has to land on the widget, not the wrapper: scaling the wrapper would
   shrink the clip along with it and crop the widget rather than fit it. A
   transform paints after layout, so the wrapper's own box is unaffected either
   way, which is exactly why the clip above is still needed. The two steps below
   cover every viewport down to 310px; above 386px the widget fits unscaled. */
.form-turnstile > * { transform-origin: left top; }
@media (max-width: 386px) {
  .form-turnstile { min-height: 58px; }
  .form-turnstile > * { transform: scale(0.88); }
}
@media (max-width: 346px) {
  .form-turnstile { min-height: 50px; }
  .form-turnstile > * { transform: scale(0.76); }
}
.form-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
/* Same fill as .btn-solid, but a <button> inherits none of a link's type styling
   and carries a UA border and background of its own. */
.form-send {
  background: var(--accent);
  color: #0a0c14;
  font-family: var(--font-sans);
  font-weight: 600;
  font-size: 13.5px;
  line-height: 1.4;
  padding: 12px 24px;
  border: 0;
  border-radius: 5px;
  cursor: pointer;
}
.form-send:hover { background: color-mix(in srgb, var(--accent) 86%, white); }
.form-send:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.form-send[disabled] { opacity: 0.55; cursor: not-allowed; }
/* The status always carries its own words, never hue alone: the colour is a second
   signal for people who can see it, not the only one. */
.form-status {
  margin-top: 18px;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--body);
  max-width: 56ch;
}
.form-status:empty { display: none; }
/* Once the form is gone the confirmation is the card's only content, so the gap
   that separated it from the send button would just push it off centre. */
.form[hidden] + .form-status { margin-top: 0; }
.form-status--ok { color: var(--live); }
.form-status--err { color: var(--dev); }
.form-note {
  margin-top: 16px;
  font-size: 13px;
  line-height: 1.65;
  color: var(--muted);
  max-width: 62ch;
  text-wrap: pretty;
}

/* ===== FOOTER ===== */
.footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 20px 0px;
  border-top: 1px solid var(--rule);
  margin-top: 30px;
}
.footer-copy {
  font-size: 14px;
  color: var(--muted);
}
.footer-links {
  display: flex;
  gap: 24px;
  font-family: var(--font-mono);
  font-size: 12.5px;
}
.footer-links a {
  color: var(--mono-muted);
  text-decoration: none;
  transition: color 0.15s;
}
.footer-links a:hover { color: var(--heading); }

/* ===== PREFERENCE CARDS (projects landing) ===== */
/* Card-shaped buttons that set the theme. Selection state is CSS-driven off
   the root attribute, so a returning visitor's stored theme paints correctly
   before any script runs. Dark is the default theme, so its "selected" state
   keys off the absence of data-theme="light". */
.pref-card {
  width: 100%;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.pref-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
:root[data-theme="light"] .pref-card[data-pref="theme"][data-value="light"],
:root:not([data-theme="light"]) .pref-card[data-pref="theme"][data-value="dark"] {
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
  background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.pref-card .card-check {
  visibility: hidden;
  font-family: var(--font-mono);
  font-size: 11.5px;
  color: var(--accent-text);
  margin-top: 10px;
}
:root[data-theme="light"] .pref-card[data-pref="theme"][data-value="light"] .card-check,
:root:not([data-theme="light"]) .pref-card[data-pref="theme"][data-value="dark"] .card-check {
  visibility: visible;
}

/* Collapse: after the visitor clicks done, the whole panel folds away for
   good, one way. Further changes happen via the toggles in the main nav,
   which are always present. State lives on <html> (data-view-panel) and is
   set pre-paint from localStorage, so return visits never flash it open. */
.pref-done {
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--accent-text);
  background: none;
  border: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  padding: 0 0 2px;
  cursor: pointer;
}
.pref-done:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.pref-actions { margin-top: 24px; }
:root[data-view-panel="collapsed"] .pref-panel { display: none; }

/* Keyboard focus on the theme toggle. */
.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ===== SPACING UTILITIES (last, so they win specificity ties) ===== */
.mt-0 { margin-top: 0; }
.mt-16 { margin-top: 16px; }
.mt-24 { margin-top: 24px; }
.mt-28 { margin-top: 28px; }

/* ===== RESPONSIVE ===== */
@media (max-width: 800px) {
  .container { padding: 0 24px; }
  /* The :has(.hitl-figure) split carries (0,2,0) specificity, so a plain .split-why here
     cannot beat it; it has to be named at matching specificity to collapse to one column. */
  .grid-2, .grid-3, .grid-4, .grid-5, .split-why, .split-why:has(.hitl-figure), .split-log, .split-diagram { grid-template-columns: 1fr; }
  .split-why, .split-log, .split-diagram { gap: 32px; }
  .work-row {
    grid-template-columns: 1fr;
    gap: 16px;
    padding: 22px 18px;
    margin: 10px 0;
  }
  .log-row { grid-template-columns: 1fr; gap: 8px; }
  .details-btn { justify-self: start; }
  .map-row-4 { grid-template-columns: repeat(2, 1fr); }
  .cta-banner {
    flex-direction: column;
    align-items: flex-start;
    gap: 20px;
  }
  .form-card { padding: 20px 17px; }
  .form-send { width: 100%; }
  .footer { flex-wrap: wrap; gap: 12px; }
  /* Below 800px the subnav wraps and two pinned tiers would eat the viewport.
     Fall back to the same behaviour every other page has: subnav pinned, strip
     in flow. */
  .subnav-stack { position: static; }
  .subnav-stack > .subnav:first-child {
    position: sticky;
    top: var(--nav-h);
    z-index: 99;
  }
  :root:has(.subnav-stack) { --anchor-offset: 60px; }
  /* The wrapped subnav's height is content-dependent here, so a fixed sticky
     offset cannot track it; let the table header scroll normally. */
  .bp-table-sticky th { position: static; }
}

/* Bump body copy up a step on phones: the desktop sizes read small on a handset next to
   the ~16px most sites use. Reading text only. Headings are already large enough, the
   mono chrome (kickers, tags, badges, nav) is meant to stay quiet, and the diagram text
   is left to its own tuning. Line-heights are unitless, so they scale with the size. */
@media (max-width: 640px) {
  .prose-p,
  .section-lede,
  .callout,
  .work-sub,
  .work-desc { font-size: 16.5px; }
  .hero-desc { font-size: 17px; }
  .card-desc,
  .card-note { font-size: 15.5px; }
  .card-desc-sm,
  .bp-table td { font-size: 15px; }
  /* 16px is the floor iOS Safari will render a field at without zooming the page
     on focus; anything smaller and tapping the first input rescales the layout. */
  .form-input { font-size: 16px; }
  .form-status { font-size: 15.5px; }
  .form-note { font-size: 14px; }
  .hitl-caption-body { font-size: 16.5px; }
  .note { font-size: 14px; }
  .tree { font-size: 13px; }
}

/* ===== SCREENSHOT BUTTON ===== */
.card.has-shot { position: relative; padding-right: 46px; }
.shot-btn {
  position: absolute;
  top: 14px;
  right: 14px;
  width: 30px;
  height: 30px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: 6px;
  color: var(--muted);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.shot-btn:hover {
  color: var(--accent-text);
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.shot-btn svg { width: 16px; height: 16px; display: block; }

/* ===== GALLERY MODAL ===== */
.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px 20px;
  background: rgba(5,6,10,0.78);
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal-gallery {
  width: 100%;
  max-width: 1100px;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  background: var(--bg-panel);
  border: 1px solid var(--node-border);
  border-radius: 10px;
  overflow: hidden;
}
.modal-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 20px 24px;
  border-bottom: 1px solid var(--rule);
}
.modal-eyebrow {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mono-muted);
  margin-bottom: 6px;
}
.modal-title { font-size: 18px; font-weight: 600; color: var(--heading); }
.modal-close {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 1px solid var(--card-border);
  border-radius: 6px;
  color: var(--muted);
  font-size: 14px;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s;
}
.modal-close:hover { color: var(--heading); border-color: var(--node-border); }
.gallery-body { padding: 18px 24px 22px; }
.gallery-carousel {
  position: relative;
  width: 100%;
  overflow: hidden;
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 8px;
}
.gallery-track {
  display: flex;
  transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.gallery-slide-img {
  flex: 0 0 100%;
  width: 100%;
  max-height: calc(90vh - 220px);
  object-fit: contain;
  display: block;
}
.carousel-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(7,9,13,0.8);
  border: 1px solid var(--node-border);
  border-radius: 6px;
  color: var(--body);
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s;
}
.carousel-arrow:hover { color: var(--accent); border-color: var(--accent); }
.carousel-arrow-left { left: 12px; }
.carousel-arrow-right { right: 12px; }
.carousel-dots {
  position: absolute;
  bottom: 12px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  background: rgba(7,9,13,0.65);
  border: 1px solid var(--rule);
  border-radius: 20px;
}
.carousel-dot {
  width: 7px;
  height: 7px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(255,255,255,0.35);
  cursor: pointer;
  transition: background 0.15s, transform 0.15s;
}
.carousel-dot.active { background: var(--accent); transform: scale(1.3); }
.gallery-caption {
  min-height: 16px;
  margin-top: 12px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--muted);
}
.gallery-counter {
  margin-top: 6px;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--mono-muted);
}
@media (max-width: 768px) {
  .card.has-shot { padding-right: 22px; }
  .shot-btn { display: none; }
}

/* Narrow screens: the nav row (brand + links + two toggles) no longer fits on
   one line. Let it wrap and scroll away instead of pinning, and zero --nav-h
   so the subnav sticks to the viewport top and anchor offsets stay correct. */
@media (max-width: 640px) {
  :root { --nav-h: 0px; }
  .nav {
    position: static;
    height: auto;
    flex-wrap: wrap;
    align-items: center;
    gap: 10px 18px;
    padding: 12px 0;
  }
  .nav-right {
    flex-wrap: wrap;
    gap: 10px 16px;
  }
  .nav-links { gap: 18px; }
}

/* Landscape phones clear the 640px width rule (they are 844px+ wide) but are too
   short to spend a pinned row on the top nav. Give them the portrait treatment: un-pin
   .nav so it scrolls away, and zero --nav-h so the subnav sticks to the viewport top
   with anchor offsets still correct. Keyed on a short landscape viewport, so tall iPads
   and desktop windows keep the pinned top nav. The nav fits one line at this width, so
   only the pinning changes, not the layout. */
@media (orientation: landscape) and (max-height: 500px) {
  :root { --nav-h: 0px; }
  .nav {
    position: static;
    height: auto;
    padding: 12px 0;
  }
}

/* ===== SCROLL FX =====
   Armed BEFORE first paint by an inline head snippet that adds .fx to <html>
   (skipped under prefers-reduced-motion), so reveal targets start transparent
   on the very first frame. js/scroll.js then reveals them; the snippet's
   safety timeout removes .fx if the script never runs, so content can never
   stay hidden. No JS at all = class never added = fully static page. */

/* Reading progress bar on the nav's bottom border. Pure CSS and
   scroll-linked rather than time-based: needs no JS and stays on under
   reduced motion. */
@media screen {
  @supports (animation-timeline: scroll()) {
    .nav::after {
      content: "";
      position: absolute;
      left: 0;
      bottom: -1px;
      width: 100%;
      height: 2px;
      background: var(--accent);
      transform-origin: 0 50%;
      transform: scaleX(0);
      animation-name: fx-progress;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: scroll(root block);
    }
    @keyframes fx-progress {
      to { transform: scaleX(1); }
    }
  }
}

@media screen and (prefers-reduced-motion: no-preference) {
  /* hidden until revealed: keep in sync with REVEAL_SELECTORS in js/scroll.js */
  html.fx .hero > *,
  html.fx .label-bar,
  html.fx .section-h2,
  html.fx .section-lede,
  html.fx .legend,
  html.fx .window--demo,
  html.fx .cycle-svg,
  html.fx .prose-p,
  html.fx .callout,
  html.fx .grid-2 > *,
  html.fx .grid-3 > *,
  html.fx .grid-4 > *,
  html.fx .grid-5 > *,
  html.fx .card-stack > *,
  html.fx .work-row,
  html.fx .log-row,
  html.fx .sysmap,
  html.fx .panel,
  html.fx .figure,
  html.fx .tree,
  html.fx .cta-banner,
  html.fx .form-card,
  html.fx .bp-table {
    opacity: 0;
    transform: translateY(12px);
    transition:
      opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1),
      transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
  }
  /* keep the existing hover tints alive on revealed interactive rows */
  html.fx .card-link {
    transition:
      opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1),
      transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
      border-color 0.15s, background 0.15s;
  }
  html.fx .work-row {
    transition:
      opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1),
      transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
      background 0.15s;
  }
  html.fx .fx-in { opacity: 1; transform: none; }
  html.fx .fx-instant { transition: none; }
  html.fx .card-link.fx-instant,
  html.fx .work-row.fx-instant {
    transition: border-color 0.15s, background 0.15s;
  }

  /* system map cascade */
  html.fx .sysmap .map-node,
  html.fx .sysmap .map-gateway {
    opacity: 0.25;
    transform: translateY(6px);
    transition:
      opacity 0.45s cubic-bezier(0.4, 0, 0.2, 1),
      transform 0.45s cubic-bezier(0.4, 0, 0.2, 1),
      box-shadow 0.45s ease;
    transition-delay: calc(var(--fx-i, 0) * 90ms);
  }
  html.fx .sysmap .map-line {
    transform: scaleY(0);
    transform-origin: top;
    transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
    transition-delay: calc(var(--fx-i, 0) * 90ms);
  }
  html.fx .sysmap.fx-map-on .map-node,
  html.fx .sysmap.fx-map-on .map-gateway { opacity: 1; transform: none; }
  html.fx .sysmap.fx-map-on .map-line { transform: scaleY(1); }
  html.fx .sysmap.fx-map-on .map-gateway {
    box-shadow: 0 0 26px color-mix(in srgb, var(--accent) 14%, transparent);
  }
  html.fx .sysmap.fx-instant .map-node,
  html.fx .sysmap.fx-instant .map-line,
  html.fx .sysmap.fx-instant .map-gateway { transition: none; }

  /* build log: dates tick on as their phase row reveals */
  html.fx .log-row .log-date {
    position: relative;
    color: var(--mono-muted);
    transition: color 0.5s ease 0.1s;
  }
  html.fx .log-row .log-date::before {
    content: "";
    position: absolute;
    left: -18px;
    top: 8px;
    width: 10px;
    height: 1px;
    background: var(--accent);
    transform: scaleX(0);
    transform-origin: left;
    transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1) 0.1s;
  }
  html.fx .log-row.fx-in .log-date { color: var(--accent-text); }
  html.fx .log-row.fx-in .log-date::before { transform: scaleX(1); }
}
