/* ============================================================
   Landing page — Alec McLeod Consulting

   Layout and components. Requires eink.css (tokens, fonts, reset)
   to be loaded first; this file defines no tokens of its own.

   Sections run top-of-page downward, in roughly the order a reader
   meets them. Responsive overrides live beside the rule they modify
   rather than in a block at the end, so a component reads as one
   unit.
   ============================================================ */

/* ---------- 1. Scroll progress --------------------------- */
/* Amber: the e-reader progress indicator. The only place the accent
   covers any horizontal distance, and it is 1px tall. */

/* Absolutely positioned inside the sticky masthead, sitting on its
   bottom rule — so the progress line travels with the header instead
   of floating detached at the top of the viewport. */
.progress {
  position: absolute;
  bottom: -1px;
  left: 0;
  height: 2px;
  width: 100%;
  transform: scaleX(0);
  transform-origin: 0 50%;
  background: var(--amber);
  will-change: transform;
}

/* Prefer the native scroll-driven animation: no scroll listener,
   no main-thread work, and it keeps working if site.js fails.
   site.js only installs its own fallback when this is absent. */
@supports (animation-timeline: scroll()) {
  .progress {
    animation: progress-grow 1s linear forwards;
    animation-timeline: scroll(root block);
  }
}

@keyframes progress-grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* ---------- 2. Shell ------------------------------------- */

.wrap {
  width: 100%;
  max-width: var(--wide);
  margin-inline: auto;
  padding-inline: clamp(1.25rem, 5vw, 3.5rem);
  position: relative;
  z-index: 2;
}

/* ---------- 3. Header ------------------------------------ */
/* Sticky, and compacts once it leaves the top of the page — which
   removes the need for a scroll affordance in the hero, since the
   nav is always reachable. */

#sentinel {
  position: absolute;
  top: 0;
  height: 1px;
  width: 1px;
}

.masthead {
  position: sticky;
  top: 0;
  z-index: 40;
  background: var(--ink-0);
  padding-block: var(--s5);
  border-bottom: 1px solid var(--ink-2);
  transition: padding-block var(--t-med);
}

/* Where scroll timelines exist, the header shrinks continuously across
   the first 140px of scroll rather than snapping between two states at
   a threshold. The class toggle below is the fallback; the animation
   outranks it wherever it runs. */
@supports (animation-timeline: scroll()) {
  .masthead {
    animation: header-shrink 1s linear forwards;
    animation-timeline: scroll(root block);
    animation-range: 0 140px;
  }
}

@keyframes header-shrink {
  from { padding-block: 1.5rem; }   /* --s5 */
  to   { padding-block: 0.75rem; }  /* --s3 */
}

/* Compact state. Only the padding and the descriptor change — the
   wordmark must not resize, or the whole header jitters as it
   crosses the threshold. */
.masthead.is-stuck { padding-block: var(--s3); }

/* Centre, not baseline. Baseline-aligning a 25px wordmark against a
   44px nav (the toggle's touch target sets that height) pushed the
   wordmark down 8px and left 40px above it against 36px below — an
   asymmetry that read as "too much space underneath". */
.masthead-inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s4) var(--s6);
}

/* One line. "Alec McLeod Consulting" already says both the who and
   the what, so a second descriptor line was repeating itself. */
.wordmark {
  display: block;
  font-family: var(--font-display);
  font-size: 1.375rem;
  font-weight: 450;
  letter-spacing: -0.006em;
  color: var(--ink-5);
  text-decoration: none;
  line-height: 1.15;
  /* Tappable height; the row is centre-aligned so this shifts nothing. */
  padding-block: var(--s2);
}
.wordmark:hover { text-decoration: none; }

/* Anchor targets must clear the sticky header. */
section[id] { scroll-margin-top: 5.5rem; }

.nav {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  /* No row gap — the links' own padding-block supplies vertical
     separation once the nav wraps, and adding gap on top of a
     44px touch target opens a dead band on narrow screens. */
  gap: 0 var(--s5);
}

.nav a,
.invert {
  font-family: var(--font-mono);
  font-size: var(--size-label);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.13em;
  color: var(--ink-3);
  text-decoration: none;
  transition: color var(--t-fast);
}
.nav a { padding-block: var(--s3); }
.nav a:hover,
.invert:hover { color: var(--ink-5); text-decoration: none; }

/* Nav underline grows out from the left rather than fading in. */
.nav a {
  position: relative;
  text-decoration: none;
}
.nav a::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--s3) - 3px);
  height: 1px;
  background: var(--ink-5);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--t-med);
}
.nav a:hover::after { transform: scaleX(1); }

/* Icon only. Sits behind a hairline divider so it reads as a control
   rather than a fifth nav destination. The accessible name lives on
   aria-label, which the markup sets and site.js keeps in sync. */
.invert {
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  min-height: 44px;
}
.invert::before {
  content: '';
  width: 0.8125rem;
  height: 0.8125rem;
  border: 1px solid currentColor;
  /* Half-filled square: a panel mid-refresh */
  background: linear-gradient(90deg, currentColor 50%, transparent 50%);
}

/* Mobile-first: the anchor links are hidden by default and only appear
   once there is genuinely room for the header to sit on one line.

   Measured: wordmark + nav needs 653px of content with the tightened
   gaps below, and 768px of viewport yields 691px — a 38px margin. The
   previous rule hid the links below 40rem, which left a ~120px band
   where they showed but dropped onto a second row. Written as
   `min-width` rather than `max-width: 48rem` on purpose: the latter
   matches *at* 768px and would drop the nav on iPad portrait exactly.

   Hiding rather than collapsing into a hamburger: this is a single page
   with four short sections, and a menu would add a panel, a focus trap,
   and aria-expanded state to replace about two screens of scrolling.
   Headings stay correctly marked up, so screen-reader users still
   navigate by heading. */
.nav a { display: none; }

@media (min-width: 48rem) {
  .nav a { display: inline-block; }

  .invert {
    margin-left: var(--s2);
    padding-left: var(--s4);
    border-left: 1px solid var(--ink-2);
  }
}

/* Tightened gaps below the widest tier — this is what buys the 38px of
   margin that keeps the links on one line at 768px. */
@media (max-width: 62rem) {
  .nav { gap: 0 var(--s4); }
}

/* ---------- 4. Hero -------------------------------------- */

.hero {
  /* Symmetric. It was clamp(--s7, 8vw, --s9) over --s7, which resolved
     to 96px above and 48px below — a 2:1 split that made the section
     look like it had slipped down. */
  padding-block: clamp(var(--s7), 6vw, var(--s8));
  border-bottom: 1px solid var(--ink-2);
  position: relative;
}

/* Copy left, running workflow right. Single column until there is
   genuinely room for both — the diagram is worthless squeezed. */
.hero-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s6);
  align-items: center;
}

/* Two columns hold down to 40rem. The headline keeps a constant ~15
   characters per line at every width in that range, because the copy
   column and the fluid type size scale together — so the layout does
   not actually degrade, and the 62rem breakpoint was forcing a stacked
   row onto tablets that had room to spare. */
@media (min-width: 40rem) {
  .hero-grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.02fr);
    gap: var(--s7);
    /* The copy block — eyebrow, headline, button — is one unit, and the
       figure is centred against all of it. This works now that the
       button sits close under the headline: the block's centre falls
       ~20px below the headline's own centre, so the figure reads as
       level with the headline rather than with the button. It did not
       work when the button hung 48px lower down. */
    align-items: center;
  }
}

.hero-copy { min-width: 0; }

/* No bleed. Letting the figure overhang the measure pushed it 67px into
   the right page margin, so it read as spilling out of the layout rather
   than sitting in it. It now ends flush with the right edge of the card
   grid and the footer below. */

/* Stacked, the figure was running the full column width and reading as
   the loudest thing in the hero, with a 64px dead band either side of
   it. Capped and centred it becomes a supporting illustration again —
   it is symmetrical, so centring looks deliberate here even though the
   rest of the page holds a strict left axis. */
/* Below the two-column breakpoint the figure is dropped, not stacked.
   On its own row it cost ~230px of height before the first service and
   read as an oversized ornament; the headline and the button carry the
   hero perfectly well without it. Nothing else depends on it — the
   collector stack's rAF loop is already gated on the figure being
   on screen, so a hidden figure means no work at all. */
@media (max-width: 40rem) {
  .hero-figure { display: none; }
}

/* Same eyebrow treatment as the sections, so every label on the page
   reads as one system. The square stays here only — this one is a
   status light ("available"), not a section marker. */
.eyebrow {
  display: flex;
  align-items: center;
  gap: 0.7em;
  margin-bottom: var(--s5);
  color: var(--amber);
}

.eyebrow::before {
  content: '';
  width: 7px;
  height: 7px;
  background: currentColor;
  flex: none;
}

.hero h1 { margin-bottom: 0; max-width: 22ch; }

/* Close under the headline — this gap is what keeps the copy block
   compact enough for the figure to centre against it convincingly. */
.hero-actions { margin-top: var(--s6); }

/* Amber outline at rest, amber text on hover. The previous version
   used an ink border with a single amber underline, which read as a
   detail rather than as the page's primary action. */
.cta {
  display: inline-flex;
  align-items: center;
  gap: 0.75em;
  min-height: 44px;
  padding: 0.85rem 1.6rem;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  text-decoration: none;
  color: var(--ink-5);
  border: 1px solid var(--amber);
  border-radius: var(--radius);
  background: transparent;
  cursor: pointer;
  transition: color var(--t-fast), background-color var(--t-fast);
}
.cta:hover {
  color: var(--amber);
  text-decoration: none;
}
.cta:disabled { opacity: 0.5; cursor: default; }
.cta:active { transform: translateY(1px); }

/* The arrow leads the eye toward the action on hover. */
.cta .arrow {
  transition: transform var(--t-fast);
  will-change: transform;
}
.cta:hover .arrow { transform: translateX(4px); }



/* ---------- 5. Hero workflow figure --------------------- */

.hero-figure { min-width: 0; }

.flow {
  width: 100%;
  height: auto;
  display: block;
  overflow: visible;
  --lead: 700ms;
}

/* Connectors. Each draws itself in, then stays. */
/* The paths carry pathLength="1" in the markup, which normalises
   every connector to the same length — so one dasharray draws them
   all in at a uniform rate regardless of their actual geometry. */
.wire {
  stroke: var(--ink-2);
  stroke-width: 1.6;
  fill: none;
  stroke-dasharray: 1 1;
  stroke-dashoffset: 1;
  animation: wire-draw 900ms var(--ease-soft) var(--wd, 0ms) both;
}

@keyframes wire-draw {
  from { stroke-dashoffset: 1; }
  to   { stroke-dashoffset: 0; }
}

/* Nodes.
   Scoped to .node-box, not `.node rect` — the latter also matched the
   collector's stack bars and overrode their fill with the page colour
   (specificity 0,1,1 beats 0,1,0), making every bar invisible. */
.node-box {
  stroke: var(--ink-4);
  stroke-width: 1.6;
  fill: var(--ink-0);
}
.node-glyph {
  stroke: var(--ink-2);
  stroke-width: 1.6;
  stroke-linecap: round;
}

/* Each step node's three glyph lines light in sequence during that
   node's processing window, so the node reads as data moving through
   it rather than as a box that merely blinks. --gl staggers the three
   lines; the branch's own --T keeps them tied to that chain's clock. */
.glyph-line {
  animation: glyph-scan var(--T) var(--ease-soft)
             calc(var(--lead) + var(--gl, 0) * 80ms) infinite;
}

/* The trigger is a source, so its lines fire as dots *leave* rather
   than when they arrive. Each of its three lines borrows one branch's
   period, so a line flickers exactly as that branch's dot departs —
   three unequal clocks driving one node, which reads as a queue being
   drained rather than a box on a timer. */
.node--trigger .glyph-line { animation-name: glyph-emit; }
.node--trigger .glyph-line:nth-of-type(1) { --T: 2.15s; }
.node--trigger .glyph-line:nth-of-type(2) { --T: 2.55s; }
.node--trigger .glyph-line:nth-of-type(3) { --T: 2.9s; }

@keyframes glyph-emit {
  0%        { stroke: var(--ink-4); }
  10%, 22%  { stroke: var(--ink-2); }
  40%, 100% { stroke: var(--ink-4); }
}

/* Monochrome, deliberately. Amber is reserved for the travelling dots
   and the node LEDs; when the glyph lines also went amber the diagram
   had accent in four places at once and the dots stopped reading as
   the thing moving. Lit state matches the collector's bar ink. */
@keyframes glyph-scan {
  0%, 32%   { stroke: var(--ink-2); }
  40%, 50%  { stroke: var(--ink-4); }
  60%, 100% { stroke: var(--ink-2); }
}

.node { animation: node-in 620ms var(--ease) var(--nd, 0ms) both; }

@keyframes node-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}

/* ---- Branch clocks ---------------------------------------------
   Each of the three chains runs on its OWN period. That is the whole
   design: the only ordering that matters is *within* a branch — a
   signal must reach its step before that step forwards one on — and
   that holds because stage A and stage B of a branch share one --T
   and occupy non-overlapping windows inside it.

   Making the periods unequal is deliberate. A single global clock
   (the previous version) kept the stages ordered but made all three
   branches fire in lockstep, which reads as mechanical. Unequal
   periods drift against each other and never repeat the same
   arrangement, so the graph looks alive without any randomness.

   Windows inside one --T:
     0–36%    stage A travels, trigger to step
     36–56%   step LED lit — processing
     56–100%  stage B travels, step to collector; arrives ON the
              iteration boundary, which is what site.js listens for
   -------------------------------------------------------------- */

.br1 { --T: 2.15s; }
.br2 { --T: 2.55s; }
.br3 { --T: 2.9s; }

/* Node LEDs. Amber only while that node is handling something. */
.led {
  fill: var(--amber);
  opacity: 0.16;
}

/* The trigger is a source, not a step — it breathes on its own short
   period rather than trying to sync with three different branches. */
.led--trigger {
  animation: led-trigger 1.5s var(--ease-soft) var(--lead) infinite;
}
.led--step {
  animation: led-step var(--T) var(--ease-soft) var(--lead) infinite;
}

@keyframes led-trigger {
  0%, 100% { opacity: 0.3; }
  45%      { opacity: 1; }
}
@keyframes led-step {
  0%, 30%   { opacity: 0.16; }
  38%, 52%  { opacity: 1; }
  64%, 100% { opacity: 0.16; }
}

/* Collector LED is flashed by site.js on each real arrival. */
.led--out { opacity: 0.16; }

/* ---- Collector stack -------------------------------------------
   Static bars are the no-JS fallback. When html.js-stack is set,
   site.js takes over with .live-bar and these are hidden. */

/* Drawn to match .node-glyph exactly — same 1.6 thickness, same round
   ends, same ink — so a full collector looks like the other nodes'
   content rather than like a different kind of object. */
.stack-bar { fill: var(--ink-2); }

.stack-live { display: none; }

html.js-stack .stack { display: none; }
html.js-stack .stack-live { display: block; }

/* Bars are positioned at the bottom slot in markup; site.js moves
   them up the stack with translateY as newer ones arrive. */
.live-bar {
  fill: var(--ink-4);
  opacity: 0;
  transform-box: fill-box;
  transform-origin: left center;
  transform: scaleX(0);
  transition: transform 460ms var(--ease), opacity 460ms var(--ease);
}

/* ---- Signals ---------------------------------------------------
   Positioned along the connector paths via offset-path, so the
   diagram's geometry is declared once, in the SVG. */

.sig {
  fill: var(--amber);
  opacity: 0;
}

@supports (offset-path: path('M0 0 H1')) {
  .sig {
    offset-rotate: 0deg;
    animation-duration: var(--T);
    animation-timing-function: linear;
    animation-iteration-count: infinite;
    animation-delay: var(--lead);
  }
  .sig-a { animation-name: sig-a; }
  .sig-b { animation-name: sig-b; }

  /* Stage A: trigger out to the three steps. */
  .sig1 { offset-path: path('M108 200 C152 200 156 100 200 100'); }
  .sig2 { offset-path: path('M108 200 H200'); }
  .sig3 { offset-path: path('M108 200 C152 200 156 300 200 300'); }
  /* Stage B: each step in to the collector. */
  .sig4 { offset-path: path('M288 100 C332 100 336 200 380 200'); }
  .sig5 { offset-path: path('M288 200 H380'); }
  .sig6 { offset-path: path('M288 300 C332 300 336 200 380 200'); }
}

@keyframes sig-a {
  0%        { offset-distance: 0%;   opacity: 0; }
  6%        { opacity: 1; }
  30%       { opacity: 1; }
  36%       { offset-distance: 100%; opacity: 0; }
  100%      { offset-distance: 100%; opacity: 0; }
}

/* Ends exactly at the iteration boundary so `animationiteration`
   fires the moment the signal lands — that event is what feeds the
   collector stack. Do not add a trailing hold here. */
@keyframes sig-b {
  0%, 56%   { offset-distance: 0%;   opacity: 0; }
  62%       { opacity: 1; }
  97%       { opacity: 1; }
  100%      { offset-distance: 100%; opacity: 0; }
}

/* ---------- 6. Sections ---------------------------------- */

.section {
  padding-block: var(--s8);
  border-bottom: 1px solid var(--ink-2);
}

/* Section labels sit as eyebrows above the heading rather than in a
   left rail. The rail cost ~184px of horizontal space on every
   section and pushed the card grid into a narrow column, which is
   what made the page feel off-axis. Amber makes them read as
   wayfinding markers — the same "live signal" job the accent does
   everywhere else. */
.section-eyebrow {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--size-label);
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--amber);
  /* Close to the card eyebrow's own gap, so the two label-to-heading
     relationships read as the same thing at different scales. */
  margin-bottom: var(--s3);
}
.section-eyebrow .n { opacity: 0.62; }

.section h2,
.contact h2 { margin-bottom: 0; }

/* ---------- 7. Bio infotip ------------------------------ */
/* A headshot beside the contact form crowded it and pushed the form
   into a narrow column. As a popover on the "Why me" heading the photo
   is available to anyone curious without costing the layout anything. */

/* The section that owns the popover has to outrank the sections after
   it. `.wrap` is `position: relative; z-index: 2`, so every section's
   wrap is its own stacking context — which means the card's z-index is
   scoped *inside* this section and cannot rise above the next section's
   wrap at the same z-index. Without this the Contact heading painted
   straight over the open card, which looks like a missing background but
   is a stacking problem. */
#background {
  position: relative;
  z-index: 3;
}

.infotip {
  position: relative;
  display: inline-flex;
  vertical-align: middle;
  margin-left: 0.3em;
}

.infotip-btn {
  width: 1.375rem;
  height: 1.375rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--ink-2);
  border-radius: 50%;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  line-height: 1;
  color: var(--ink-3);
  transition: color var(--t-fast), border-color var(--t-fast);
}
.infotip-btn:hover,
.infotip:focus-within .infotip-btn,
.infotip-btn[aria-expanded='true'] {
  color: var(--amber);
  border-color: var(--amber);
}

.infotip-card {
  position: absolute;
  top: calc(100% + 0.6rem);
  left: 50%;
  translate: -50% 0;
  z-index: 30;
  width: 14.5rem;
  padding: var(--s4);
  background: var(--ink-0);
  border: 1px solid var(--ink-2);
  border-radius: var(--radius);
  display: flex;
  flex-direction: column;
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--t-fast), visibility var(--t-fast);
}

/* Hover for pointers, focus-within for keyboards, .is-open for touch
   (site.js toggles it on click). All three paths reach the same state. */
.infotip:hover .infotip-card,
.infotip:focus-within .infotip-card,
.infotip-card.is-open {
  opacity: 1;
  visibility: visible;
}

.infotip-card img {
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: var(--radius);
  /* Monochrome for the same reason as everywhere else: a full-colour
     photograph is the one element that would break the palette. */
  filter: grayscale(1) sepia(0.18) contrast(1.03);
}

/* Photo, then a name-and-role pair. The pair is set tight together so
   it reads as one block rather than three evenly spaced items. */
.infotip-name {
  margin-top: var(--s4);
  font-family: var(--font-text);
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.004em;
  color: var(--ink-5);
  line-height: 1.2;
}

.infotip-role {
  margin-top: 0.3em;
  font-family: var(--font-mono);
  font-size: var(--size-label);
  letter-spacing: 0.08em;
  line-height: 1.6;
  color: var(--ink-3);
}

/* Near the right edge on narrow screens the centred card would clip. */
@media (max-width: 34rem) {
  .infotip-card { left: 0; translate: 0 0; }
}

/* ---------- 8. Cards ------------------------------------- */
/* One primitive for every content section. Services, Approach, and
   Background were three different list treatments — stacked entries
   with an icon rail, stacked entries with ordinals, and a grid — which
   read as three unrelated designs stacked vertically. A single card
   grid gives the page one rhythm and one set of spacing rules. */

.cards {
  margin-top: var(--s6);
  display: grid;
  grid-template-columns: 1fr;
  /* Stacked, the cards need more air between them than they do side
     by side — there is no column edge doing the separating. */
  gap: var(--s7);
}

/* Two tiers, not one. Jumping straight from a single column to three
   left tablet widths stuck with full-width cards and a lot of dead
   space to the right. */
@media (min-width: 40rem) {
  .cards {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--s7) var(--s6);
  }
}

@media (min-width: 58rem) {
  .cards {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--s6);
  }
}

/* No top rule. Three hairlines in a row competed with the section
   divider directly above them and chopped the block up; the eyebrow
   is a strong enough start marker on its own. */
.card { min-width: 0; }

/* Amber on the eyebrow is the hover signal — the accent marks the
   thing you are pointing at, consistent with its "live signal" job
   everywhere else on the page. */
.card-label {
  display: flex;
  align-items: center;
  gap: 0.55em;
  font-family: var(--font-mono);
  font-size: var(--size-label);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--ink-3);
  margin-bottom: var(--s3);
  transition: color var(--t-med);
}

.card-num {
  font-variant-numeric: tabular-nums;
  color: var(--ink-4);
  transition: color var(--t-med);
}

.card:hover .card-label,
.card:hover .card-num { color: var(--amber); }

.card h3 {
  margin-bottom: var(--s2);
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5em;
  color: var(--ink-5);
}

/* :not() rather than a bare `.card p` — the eyebrow is also a <p>,
   and a descendant-element selector (0,1,1) silently outranks the
   .card-label class (0,1,0), overriding its colour and size. Same
   trap as the `.node rect` / `.live-bar` collision in the figure.
   In this file, never style a bare element inside a component. */
.card p:not(.card-label) {
  color: var(--ink-4);
  font-size: 1rem;
  line-height: 1.55;
  text-wrap: pretty;
}

/* Not-yet-available card sits a tonal step back from the two that are. */
.card--soon h3 { color: var(--ink-4); }
.card--soon p:not(.card-label) { color: var(--ink-3); }

.badge {
  font-family: var(--font-mono);
  font-size: 0.5625rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.13em;
  color: var(--ink-3);
  border: 1px solid var(--ink-2);
  padding: 0.3em 0.5em 0.26em;
  white-space: nowrap;
  transform: translateY(-0.12em);
}

/* Service icons — hairline line art at the same stroke weight as the
   page's rules, so they read as part of the same drawing. */
.svc-icon {
  width: 1.125rem;
  height: 1.125rem;
  flex: none;
  overflow: visible;
  stroke: var(--ink-3);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: stroke var(--t-med);
}
.svc-icon .svc-accent { stroke: currentColor; }
.svc-icon .svc-accent-fill { fill: currentColor; stroke: none; }
.card:hover .svc-icon { stroke: var(--amber); }


/* ---------- 9. Contact ----------------------------------- */

.contact { padding-block: var(--s8); }

.form {
  margin-top: var(--s6);
  display: grid;
  gap: var(--s5);
}

/* Name and email share a row — two short fields stacked wasted a lot
   of vertical space in a form this short. */
/* Name, email, and company share one row; the message spans the full
   width beneath them. */
.field-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s5);
}

@media (min-width: 34rem) {
  .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (min-width: 52rem) {
  .field-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

.field { display: grid; gap: var(--s2); }

.field label {
  transition: color var(--t-fast);
  font-family: var(--font-mono);
  font-size: var(--size-label);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-3);
}
.field .opt { text-transform: none; letter-spacing: 0.04em; }

/* Inputs are ruled lines, not boxes — the way a printed form is
   set. Only the baseline is drawn. */
.field input,
.field textarea {
  font-family: var(--font-text);
  font-size: 1.0625rem;
  color: var(--ink-5);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--ink-2);
  padding: var(--s3) 0;
  width: 100%;
  border-radius: 0;
  transition: border-color var(--t-fast);
  min-height: 44px;
}
.field textarea {
  resize: vertical;
  min-height: 7rem;
  line-height: 1.55;
}
.field input:hover,
.field textarea:hover { border-bottom-color: var(--ink-3); }

/* Focus is an amber rule under the field plus an amber label — the
   ruled-line form language, continued. A box outline around a field
   that has no box was fighting the design.

   This is still an accessible indicator: the underline both changes
   colour and doubles in thickness across the field's full width, so it
   is not signalled by colour alone. The padding compensation keeps the
   baseline from shifting as the border grows. */
.field:focus-within label { color: var(--amber); }

.field input:focus,
.field textarea:focus {
  outline: none;
  border-bottom-color: var(--amber);
  border-bottom-width: 2px;
  padding-bottom: calc(var(--s3) - 1px);
}

.honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.form-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s5);
}

.status {
  font-family: var(--font-mono);
  font-size: var(--size-label);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  line-height: 1.5;
  color: var(--ink-3);
}
.status[data-state='error'] { color: var(--amber); }
.status[data-state='ok'] { color: var(--ink-5); }


/* ---------- 10. Footer ------------------------------------ */

.footer {
  border-top: 1px solid var(--ink-2);
  padding-block: var(--s6) var(--s7);
}
/* Not uppercased: the name sits here, and small-caps treatment of a
   personal name reads as a logo rather than a signature. */
.footer-mark {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.04em;
  color: var(--ink-3);
}

/* ---------- 11. Load-in ----------------------------------- */
/* Hero elements rise on load, staggered by an inline --d.

   Deliberately a CSS animation with `both` rather than a
   JS-toggled class: if animations never run, the element simply
   sits at its natural visible state. Nothing here can strand
   content off-screen. */

.rise {
  animation: rise 720ms var(--ease) var(--d, 0ms) both;
}

@keyframes rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* ---------- 12. Scroll reveal ---------------------------- */
/* Content defaults to VISIBLE. The hidden state is opt-in via
   html.js-reveal, which the inline head script only adds once it
   has confirmed IntersectionObserver exists and motion is not
   reduced. This ordering matters: a reveal-on-scroll built the
   other way round leaves the whole page blank whenever the
   observer fails to fire — a hidden tab, a prerender pass, or a
   throttled background renderer are all enough to trigger it.
   site.js also force-reveals everything after 1.5s as a backstop. */

.reveal { opacity: 1; }

html.js-reveal .reveal { opacity: 0; transition: opacity 380ms var(--ease); }
html.js-reveal .reveal.is-in { opacity: 1; }

/* Children stagger in behind the section itself, so a section
   assembles rather than appearing all at once. Indices are static
   because the counts are known and fixed. */
html.js-reveal .reveal .section-eyebrow,
html.js-reveal .reveal h2,
html.js-reveal .reveal .card,
html.js-reveal .reveal .form {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity var(--t-reveal), transform var(--t-reveal);
}

html.js-reveal .reveal.is-in .section-eyebrow,
html.js-reveal .reveal.is-in h2,
html.js-reveal .reveal.is-in .card,
html.js-reveal .reveal.is-in .form {
  opacity: 1;
  transform: none;
}

html.js-reveal .reveal.is-in .section-eyebrow { transition-delay: 0ms; }
html.js-reveal .reveal.is-in h2             { transition-delay: 60ms; }
html.js-reveal .reveal.is-in .card:nth-child(1) { transition-delay: 200ms; }
html.js-reveal .reveal.is-in .card:nth-child(2) { transition-delay: 290ms; }
html.js-reveal .reveal.is-in .card:nth-child(3) { transition-delay: 380ms; }
html.js-reveal .reveal.is-in .form { transition-delay: 200ms; }

@media (prefers-reduced-motion: reduce) {
  html.js-reveal .reveal,
  html.js-reveal .reveal .section-eyebrow,
  html.js-reveal .reveal h2,
  html.js-reveal .reveal .card,
  html.js-reveal .reveal .form {
    opacity: 1;
    transform: none;
  }
}

/* ---------- 13. Panel refresh flash ---------------------- */
/* Toggling the theme briefly inverts the whole page, the way an
   e-ink panel flashes black on a full refresh. 90ms, once. */

.flash {
  position: fixed;
  inset: 0;
  z-index: 90;
  pointer-events: none;
  background: var(--ink-5);
  opacity: 0;
}
html[data-flash='1'] .flash { animation: eink-refresh 90ms linear 1; }

@keyframes eink-refresh {
  0%, 60% { opacity: 1; }
  100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  html[data-flash='1'] .flash { animation: none; }
}
