/* ==========================================================================
   PALETTE. The only block to edit to change the site's colour.

   The page is deep plum at both ends with warm cream between, read top to
   bottom:

     masthead    #241028   deep plum, near-white type      (.deep)
     hero        #241028   the same plum, unbroken         (.panel)
     facts       #F7F0EE   the first step onto paper       (.band .f1)
     services    #FCF8F6   paper                           (.band .f2)
     Simplyb     #FDFAF7   the lightest paper, longest read(.band .f3)
     Retirement  #FCF8F6   paper                           (.band .f4)
     how it works#F2E8E4   one step back towards the plum  (.band .f5)
     contact     #241028   the same plum, closing it       (.deep)

   One accent: amber. #C24A0B on cream and for the button, #FFB067 lifted
   for the plum. Near-complementary to the plum, so the button cannot be
   missed, which is the whole job of a button.

   Two earlier versions are worth knowing about, because both failed in ways
   that are easy to repeat. Until 2026-09-08 this was five saturated
   mid-blues top to bottom, and 3,000 words of case study sat on them at
   5.5:1, which is legal and tiring. It then went pale blue over near-white,
   which read as safe and said nothing. The lesson from the second: a colour
   you use for text on white has to be darkened until it stops looking
   vivid, so if you want vivid, you need dark ground, not more saturation.
   Plum is also a WARM dark, which is why the cream below it agrees with it;
   a navy over warm paper read as two unrelated decisions.
   See design-principles.md.

   Bands are built from scoped custom properties, not from new selectors.
   Every rule on this page reads --ink, --muted, --line, --rule and
   --accent, and custom properties inherit, so redefining those five on a
   section recolours everything inside it. Adding a band is one rule. The
   hairlines have to darken along with their ground or they vanish into it,
   which is why each step carries its own --line and --rule.

   Everything is measured from RENDERED computed styles, never from these
   token values, because a token can be right while the selector applying it
   is wrong. That has now happened three times here, most recently when
   .deep a (0,2,0) beat .btn (0,1,0) and dropped the contact button's label
   to 1.58:1. The sweep walks all 195 text elements, resolves each one's real
   background by climbing its ancestors, and currently reports zero
   failures. Tightest margins:

     "How Rune works" label on .f5    5.50:1  (AA wants 4.5)
     "In progress" flag on .f4        5.52:1
     case-study links on .f3          6.17:1

   If you change a ground, re-run that sweep. Do not reason about it from
   the hex values alone.
   ========================================================================== */
@font-face {
  font-family: 'Bricolage Grotesque';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url(/fonts/bricolage-grotesque-800.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* These are now fallbacks. Every section paints its own ground and scopes
     its own text tokens (see THE RAMP at the foot of this stylesheet), so
     what is left here is what shows when nothing else claims it. --bg is
     the deep blue rather than a light one on purpose: the page opens and
     closes on that colour, so overscrolling at either end runs into the
     bookend it is already touching instead of a flash of pale blue. */
  --bg: #241028;            /* canvas behind everything, seen only on overscroll */
  --surface: #F7F0EE;       /* callouts, a step deeper than the ground */
  --raised: #FDFAF7;        /* chips and small raised things, the lightest */
  --ink: #1C1714;
  --muted: #4E4038;
  --line: #E4D5D1;
  --rule: #CCB6B0;          /* stronger hairline, for structural rules */
  --accent: #9C3A0C;
  --accent-soft: #F3DCC8;
  --accent-ink: #FFFFFF;

  /* One step below the page. Must recede, never compete. */
  --tint: #F2E8E4;

  /* The panel is the deep plum field, carrying near-white type. It and the
     two .deep bookends are the same colour, so the masthead, the hero and
     the contact block read as one continuous surface interrupted by paper.
     Plum is a WARM dark, which is the whole reason it is here: the reading
     bands below are warm cream, and a cool navy above warm paper was the
     thing that read as two unrelated decisions. */
  --panel-bg: #241028;
  --panel-raised: #331A38;
  --panel-fg: #F9F2FA;
  --panel-muted: #CBB2D0;
  --panel-accent: #FFB067;
  --panel-line: #3D2242;
  --panel-btn-bg: #C24A0B;
  --panel-btn-fg: #FFFFFF;

  /* The button has its own pair, and it is the one thing on the page allowed
     to shout. An earlier draft put a plum button on the plum ground and the
     single most important control nearly vanished into its own background.
     Amber against plum is near-complementary, so it cannot be missed.
     White on this orange measures 4.91:1, above the 4.5 floor. */
  --btn-bg: #C24A0B;
  --btn-fg: #FFFFFF;

  --font-display: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
  --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, 'Roboto Mono', monospace;

  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px; --s5: 24px;
  --s6: 32px; --s7: 48px; --s8: 72px; --s9: 104px;
  --speed: 150ms;
}

/* ---------- NO DARK MODE, ON PURPOSE ----------
   The page is light for every visitor, whatever their operating system is
   set to. It used to follow prefers-color-scheme, which meant anyone on a
   dark-mode machine got a near-black page under the blue hero and never saw
   the design as intended. That is a real audience: this page is aimed at
   small business owners, plenty of whom run dark mode without ever having
   chosen it, and none of whom should have to change an OS setting to see
   the site properly.

   To bring dark mode back, add a @media (prefers-color-scheme: dark) block
   redefining the same tokens as :root above, plus a rule un-doing the
   brightness(0) filter on .wordmark .mark so the white logo stays white.
   Every pairing needs re-measuring if you do: a light hero field with
   near-black type cannot simply be inverted. -------------------------- */

/* ---------- base ---------- */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0; background: var(--bg); color: var(--ink);
  font-family: var(--font-body); font-size: 17px; line-height: 1.62;
  -webkit-font-smoothing: antialiased;
}
img { max-width: 100%; }
p { margin: 0; }
::selection { background: var(--accent-soft); color: var(--ink); }

/* Heavy sans, not serif. Weight 800 is most of what reads as "clean and
   professional"; the face is what makes it Rune's rather than Windows'. Until
   2026-09-08 this was the system stack, which meant Segoe UI Black on Windows,
   SF on a Mac and Roboto on Android: three different sites. One 22 KB file
   fixes that and the page still makes no request off this origin.
   Tracking tightens as size rises: -0.02em from 30px up, -0.01em below. */
h1, h2, h3 { font-family: var(--font-display); font-weight: 800; margin: 0; text-wrap: balance; letter-spacing: -0.02em; }

a {
  color: var(--accent);
  text-underline-offset: 3px; text-decoration-thickness: 1px;
  transition: color var(--speed) ease, background-color var(--speed) ease,
              border-color var(--speed) ease, opacity var(--speed) ease;
}
a:hover { text-decoration-thickness: 2px; }
a:active { opacity: 0.7; }
a:focus-visible, button:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--accent); color: var(--accent-ink); padding: var(--s3) var(--s4);
}
.skip:focus { left: 0; }

/* ---------- measure ---------- */
.shell { max-width: 1140px; margin: 0 auto; padding: 0 var(--s5); }
@media (min-width: 760px) { .shell { padding: 0 var(--s7); } }
.measure { max-width: 66ch; }

/* small structural label, used sparingly */
.label {
  font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--muted);
}

/* ---------- masthead ---------- */
.masthead {
  position: sticky; top: 0; z-index: 50;
  border-bottom: 1px solid var(--rule);
}
.masthead-in {
  max-width: 1140px; margin: 0 auto; padding: var(--s3) var(--s5);
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4);
}
@media (min-width: 760px) { .masthead-in { padding: var(--s4) var(--s7); } }
.wordmark { text-decoration: none; line-height: 1.1; }
/* Josh's logo (_Attachments/rune/rune text logo.png in the vault) is a white
   mark on a transparent background. The masthead sits on the light page
   ground, so brightness(0) recolours the white to solid black. It keeps the
   alpha channel, so no second asset is needed. */
.wordmark .mark {
  display: block; height: 26px; width: auto; filter: brightness(0);
  transition: opacity var(--speed) ease;
}
@media (min-width: 760px) { .wordmark .mark { height: 32px; } }
.wordmark i {
  display: block; font-style: normal; font-family: var(--font-mono);
  font-size: 0.62rem; letter-spacing: 0.11em; text-transform: uppercase;
  color: var(--muted); margin-top: 5px;
}
.wordmark:hover .mark { opacity: 0.72; }
/* ---------- navigation ----------
   Four items, not five: the two case studies are the two longest labels in
   the bar and they are the same kind of thing, so they collapse under
   Projects. That is what stopped the row wrapping on a phone.

   The grouping is a details element, which discloses, takes keyboard focus and
   toggles with no script at all. Only two things need JavaScript: closing on
   Escape and on an outside click, neither of which details does natively,
   and both of which are refinements rather than the feature. */
.masthead nav { display: flex; align-items: center; }
.masthead nav ul {
  list-style: none; margin: 0; padding: 0;
  display: flex; align-items: center; gap: var(--s5); font-size: 0.85rem;
}
/* Every target clears 44px through padding rather than font size, so the bar
   stays visually light while the tap area is honest. All five links used to
   be about 17px tall. */
.masthead nav a, .masthead summary {
  color: var(--muted); text-decoration: none;
  display: flex; align-items: center; min-height: 44px;
}
.masthead nav a:hover, .masthead summary:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 5px; }
.masthead nav a:focus-visible, .masthead summary:focus-visible { outline-color: var(--accent); }
.masthead nav a:active { color: var(--accent); }

.masthead details { position: relative; }
.masthead summary { cursor: pointer; gap: 7px; list-style: none; }
.masthead summary::-webkit-details-marker { display: none; }
.masthead summary::after {
  content: ''; width: 6px; height: 6px; margin-top: -3px;
  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: rotate(45deg); transition: transform var(--speed) ease;
}
.masthead details[open] > summary { color: var(--ink); }
.masthead details[open] > summary::after { transform: rotate(-135deg); margin-top: 2px; }
/* Hide the panel explicitly. ".masthead nav ul { display: flex }" matches the
   nested list too, and a declared display overrides how a closed details element
   would otherwise hide its own content. Left implicit, the links keep a
   layout box: invisible, but measurable and reachable by Tab. */
.masthead details > ul { display: none; }
.masthead details[open] > ul {
  display: flex;
  position: absolute; top: calc(100% + 4px); left: -14px; z-index: 60;
  flex-direction: column; align-items: stretch; gap: 0; min-width: 224px;
  background: var(--deep-bg); border: 1px solid var(--line); border-radius: 3px;
  padding: 4px 0; box-shadow: 0 10px 28px rgba(0, 0, 0, .3);
}
.masthead details > ul a { padding: 0 14px; }

/* The menu button exists only when scripting does. With JavaScript off there
   is nothing to toggle, so the list stays visible and the button never
   appears rather than appearing and doing nothing. */
.navtoggle { display: none; }
.js .navtoggle {
  display: inline-flex; align-items: center; gap: 10px;
  min-height: 44px; padding: 0 12px; margin-right: -12px;
  background: none; border: 0; cursor: pointer;
  color: var(--muted); font: inherit; font-size: 0.85rem;
}
.js .navtoggle:hover { color: var(--ink); }
.navtoggle .bars, .navtoggle .bars::before, .navtoggle .bars::after {
  display: block; width: 18px; height: 2px; background: currentColor;
}
.navtoggle .bars { position: relative; }
.navtoggle .bars::before, .navtoggle .bars::after { content: ''; position: absolute; left: 0; }
.navtoggle .bars::before { top: -6px; }
.navtoggle .bars::after { top: 6px; }

@media (max-width: 759px) {
  .js .navtoggle { display: inline-flex; }
  /* The panel drops out of the bar rather than pushing it down, so the sticky
     header stays the same height whether the menu is open or shut. */
  .js .masthead nav > ul {
    display: none;
    position: absolute; left: 0; right: 0; top: 100%;
    flex-direction: column; align-items: stretch; gap: 0;
    background: var(--deep-bg); border-bottom: 1px solid var(--rule);
    padding: 0 var(--s5) var(--s3);
  }
  .js .masthead nav > ul[data-open] { display: flex; }
  .masthead nav > ul > li + li { border-top: 1px solid var(--line); }
  .masthead nav > ul > li > a, .masthead summary { min-height: 52px; }
  /* Inside the panel the group opens inline. A floating card over a menu that
     is itself a floating card reads as a mistake. */
  .masthead details[open] > ul {
    position: static; min-width: 0; border: 0; box-shadow: none;
    background: none; padding: 0 0 var(--s2) var(--s4);
  }
  .masthead details > ul li + li { border-top: 1px solid var(--line); }
  .masthead details > ul a { padding: 0; min-height: 48px; }
}
@media (min-width: 760px) { .js .navtoggle { display: none; } }

/* ---------- hero ----------
   One full-bleed colour field, one column, one clear action. Nothing sits
   beside the headline. The old right-hand profile card is gone: three of its
   four rows repeated information the page already states (the eyebrow gives
   the location, the intro names Josh, the contact block gives the email), and
   the one genuinely unique row, current availability, is now a line under the
   buttons where it does more work. */
/* Sized so the whole hero clears the fold on a 1440x800 laptop, which is what
   a 1440x900 screen leaves once browser chrome is subtracted. Measured, not
   guessed: it was 235px below that line before this. */
.hero {
  padding: var(--s8) 0 var(--s7);
  position: relative; overflow: hidden;
}
@media (max-width: 899px) { .hero { padding: var(--s7) 0 var(--s6); } }

/* Slow colour drift behind the hero, in place of the static banding that used
   to sit here. Both are atmosphere, and two competing atmospheres in one field
   is clutter, so the banding came out when this went in.

   Adapted from a React + Motion component: same idea, no dependencies. Once
   the framework is stripped it is four radial-gradient circles, two rotating
   around an offset origin and two drifting, under a goo filter and a blur.

   Three departures from the original, none of them preference.

   Colours: it ships violet, magenta, cyan, red and yellow. These are the
   plums and ambers already in the palette.

   No blend mode. It uses hard-light against a violet-900 gradient. These are
   plain rgba layers composited normally, which is all that is needed now the
   ground is dark: light colours over a dark field lighten it by themselves.

   An earlier version of this page had a pale hero, and every colour here was
   pre-multiplied against #D8E8F7 to fake a multiply blend. That arithmetic
   is gone with the pale ground it existed for. If the hero ever goes light
   again, it comes back: for a solid ground B and layer colour C at alpha a,
   multiply gives B*(1 - a + a*C/255) and plain compositing gives
   B*(1-a) + a*C', equal when C' = B*C/255.

   No SVG filter. The goo filter merged overlapping blobs into one shape, and
   cost a dependency on filter: url() resolving correctly. Together with the
   blend mode that is the single most reported combination for failing in
   WebKit, and it cannot be tested from here. A plain blur() carries the look
   on its own. What is left is one CSS property that has worked everywhere
   for a decade. */
.blobs {
  position: absolute; inset: -12%; pointer-events: none;
  opacity: .58;
}
/* No filter. Each blob used to carry blur(52px) plus will-change, which
   forces five permanently promoted layers each needing a large blur
   re-rasterised every frame: fine on a desktop GPU, and the likeliest reason
   a phone painted this field once and then held it still.

   The blur was nearly redundant. These are radial gradients that already
   fade to nothing; the blur was mostly hiding the hard edge where the fade
   stopped short of the element boundary. Taking the fade out to 72% removes
   the edge and the need for the filter together.

   What is left is a coloured gradient moved by a transform. There is nothing
   in that a browser can decline to do. */
.blobs span {
  position: absolute; display: block; width: 96%; height: 96%;
}
/* The two rotating blobs orbit because their transform-origin is pushed off
   centre. Spinning a circle about its own middle would render as nothing. */
.blobs .b1 { top: 2%;  left: 0;   background: radial-gradient(ellipse farthest-side at center, rgba(122,46,134,0.850) 0%, rgba(122,46,134,0.697) 18%, rgba(122,46,134,0.493) 34%, rgba(122,46,134,0.297) 50%, rgba(122,46,134,0.153) 65%, rgba(122,46,134,0.064) 78%, rgba(122,46,134,0.017) 89%, rgba(122,46,134,0.000) 100%); animation: drift-a 7s ease-in-out infinite; }
.blobs .b2 { top: 10%; left: 26%; background: radial-gradient(ellipse farthest-side at center, rgba(255,176,103,0.550) 0%, rgba(255,176,103,0.451) 18%, rgba(255,176,103,0.319) 34%, rgba(255,176,103,0.193) 50%, rgba(255,176,103,0.099) 65%, rgba(255,176,103,0.041) 78%, rgba(255,176,103,0.011) 89%, rgba(255,176,103,0.000) 100%);   transform-origin: 15% 85%; animation: orbit 9s linear infinite; }
.blobs .b3 { top: 28%; left: 50%; background: radial-gradient(ellipse farthest-side at center, rgba(58,20,66,0.800) 0%, rgba(58,20,66,0.656) 18%, rgba(58,20,66,0.464) 34%, rgba(58,20,66,0.280) 50%, rgba(58,20,66,0.144) 65%, rgba(58,20,66,0.060) 78%, rgba(58,20,66,0.016) 89%, rgba(58,20,66,0.000) 100%); animation: drift-b 8s ease-in-out infinite; }
.blobs .b4 { top: -8%; left: 58%; background: radial-gradient(ellipse farthest-side at center, rgba(194,74,11,0.620) 0%, rgba(194,74,11,0.508) 18%, rgba(194,74,11,0.360) 34%, rgba(194,74,11,0.217) 50%, rgba(194,74,11,0.112) 65%, rgba(194,74,11,0.046) 78%, rgba(194,74,11,0.012) 89%, rgba(194,74,11,0.000) 100%); transform-origin: 85% 20%; animation: orbit 12s linear infinite reverse; }

@keyframes drift-a { 0%, 100% { transform: translate3d(-16%, -22%, 0); } 50% { transform: translate3d(24%, 28%, 0); } }
@keyframes drift-b { 0%, 100% { transform: translate3d(-26%, 12%, 0); } 50% { transform: translate3d(26%, -22%, 0); } }
/* Both keyframes must name the SAME transform function. With only a `to`,
   the start value came from the base rule and the lists did not match, so
   the browser interpolated matrices instead of the angle. rotate(360deg)
   has the identity matrix, so it animated from identity to identity and
   two of the four blobs sat perfectly still in every browser. */
@keyframes orbit    { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

/* The pointer follower. Bigger and softer than the drifting four, and damped
   to 55% of the cursor's travel so it trails rather than sticks to it.

   Hidden until the pointer has actually moved, so it never flashes into a
   fixed corner on load, and hidden entirely on touch, where there is no
   cursor to follow and the JS below never runs anyway. */
.blobs .bm {
  top: 14%; left: 14%; width: 72%; height: 72%;
  background: radial-gradient(ellipse farthest-side at center, rgba(200,120,210,0.500) 0%, rgba(200,120,210,0.410) 18%, rgba(200,120,210,0.290) 34%, rgba(200,120,210,0.175) 50%, rgba(200,120,210,0.090) 65%, rgba(200,120,210,0.037) 78%, rgba(200,120,210,0.010) 89%, rgba(200,120,210,0.000) 100%);
  transform: translate3d(var(--mx, 0px), var(--my, 0px), 0);
  opacity: 0;
  transition: transform 1.1s cubic-bezier(.22, .61, .36, 1), opacity .7s ease;
}
.hero.pointer-live .blobs .bm { opacity: 1; }
@media (hover: none), (pointer: coarse) { .blobs .bm { display: none; } }

/* THE DRIFT DELIBERATELY IGNORES prefers-reduced-motion. Josh's decision,
   made knowingly, and worth writing down so nobody "fixes" it later.

   The rest of the page still honours it, and that is the point: this draws
   the line between decoration and content rather than switching the
   preference off wholesale. Under reduce, the hero entrance does not play,
   the scroll reveals do not play, smooth scrolling is off and every
   transition on the page is killed. What keeps moving is a blurred colour
   wash behind the type, on 34 to 66 second cycles, that nobody has to track
   or read, does not follow the pointer or the scroll, and never moves a word
   of text by a single pixel.

   If that judgement ever needs reversing, it is one media query:
     @media (prefers-reduced-motion: reduce) {
       .blobs .b1, .blobs .b2, .blobs .b3, .blobs .b4 { animation: none; }
     }
   Note the selector has to name the classes. ".blobs span" is (0,1,1) and
   loses to ".blobs .b1" at (0,2,0), so the obvious version of that rule
   parses fine, sits in the stylesheet looking correct, and does nothing. */
.hero .shell { position: relative; }

/* Two columns from 980px: the words on the left, proof on the right. The
   right column used to be empty apart from the drift, which on a 1920 screen
   was half the first view doing nothing. What sits there now is the live
   storefront with ONE measured number over it, in a plain frame. When there
   is a photo of Josh it takes this same slot and frame, and the storefront
   goes back to being only in the case study. The column is fixed at 420px so
   the headline's measure does not change with the viewport. */
@media (min-width: 980px) {
  .hero .shell {
    display: grid; grid-template-columns: minmax(0, 1fr) 420px;
    gap: var(--s8); align-items: center;
  }
}
.proof { margin: var(--s7) 0 0; max-width: 520px; }
@media (min-width: 980px) { .proof { margin: 0; max-width: none; } }
.proof .frame {
  overflow: hidden; border-radius: 3px;
  border: 1px solid var(--panel-line);
}
.proof img { display: block; width: 100%; height: auto; }
/* The number sits UNDER the image on a hairline, which is the same grammar
   as the facts band directly below the hero and the project sheet further
   down. It was an overlaid card with its own border, background and drop
   shadow, sitting on top of the screenshot: the only floating box on a page
   that builds everything else from ruled cells, and it covered part of the
   shop's own homepage to say so. Ruled, not boxed. */
.proof .proofstat {
  margin-top: var(--s4); padding-top: var(--s4);
  border-top: 1px solid var(--panel-line);
}
.proof .proofstat b {
  display: block; font-family: var(--font-mono); font-weight: 500;
  font-variant-numeric: tabular-nums; font-size: 1.5rem; letter-spacing: -0.02em;
  line-height: 1; color: var(--panel-fg);
}
.proof .proofstat span {
  display: block; margin-top: var(--s3); font-size: 0.88rem; line-height: 1.5;
  color: var(--panel-muted);
}
/* padding-left and border-left have to be named here to be cancelled. The
   general figcaption rule sets both, and .proof figcaption only overrides
   the properties it actually declares, so leaving them out left the caption
   indented under a stray hairline that belonged to the case-study figures. */
.proof figcaption {
  margin-top: var(--s4); padding-left: 0; border-left: 0; max-width: none;
  font-size: 0.82rem; line-height: 1.45; color: var(--panel-muted);
}
.proof figcaption a { color: var(--panel-accent); }
/* The photo fades in with the rest of the hero but does not slide: it is the
   one thing on the page that must not move. */
@keyframes proof-in { from { opacity: 0; } to { opacity: 1; } }
.proof { animation: proof-in .6s ease both; animation-delay: .2s; }
@media (prefers-reduced-motion: reduce) { .proof { animation: none; } }

/* The numbers used to sit inside the dark panel. They now get their own band
   on the light ground directly under it, so the hero stays one clean field
   and the numbers read as evidence rather than decoration. */
.factband { padding: var(--s7) 0; border-bottom: 1px solid var(--line); }
.factband .facts { margin-top: 0; border-top: 0; }

.stack { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s2); }
.stack span {
  font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.02em;
  color: var(--ink); border: 1px solid var(--line); border-radius: 2px;
  padding: 3px 7px; background: var(--raised);
}

.hero .place {
  font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--accent); margin-bottom: var(--s5);
}
.hero h1 {
  font-size: clamp(2.15rem, 5.1vw, 4.05rem);
  /* 20ch, not 15. The old hook was 41 characters and wrapped to three lines;
     the wider one is 54 and wrapped to four, which pushed the primary button
     past the fold on a 1440x700 laptop. Widening the measure rather than
     shrinking the type keeps the headline's weight and buys the line back. */
  line-height: 1.04; letter-spacing: -0.028em; max-width: 20ch;
}
/* Two-beat headline: a hook, then a lighter answer directly under it. One h1,
   two lines of different weight. Still one sentence to a screen reader, just
   paced like speech. */
.hero h1 .hook { display: block; }
.hero h1 .payoff {
  display: block; margin-top: var(--s3); max-width: 22ch;
  font-weight: 400; color: var(--accent);
  font-size: clamp(1.4rem, 2.9vw, 2.15rem); letter-spacing: -0.014em;
  line-height: 1.14;
}
.hero .lede {
  margin-top: var(--s5); max-width: 58ch; font-size: 1.1rem; line-height: 1.55;
}
.hero .actions { margin-top: var(--s6); }
.hero .lede b { font-weight: 600; }
/* Current availability. The one row worth keeping from the old profile card,
   and it reads better as a live status line under the buttons than as an
   entry in a box. */
/* Not flex: a flex container makes every child its own item, so the trailing
   full stop after the link would get pushed off by the gap. Inline-block dot
   with a plain margin keeps the sentence a sentence. */
.hero .avail { margin-top: var(--s5); font-size: 0.92rem; }
.hero .avail .dot {
  display: inline-block; width: 8px; height: 8px; border-radius: 50%;
  background: var(--accent); margin-right: var(--s3); vertical-align: middle;
}

/* Small phones. On a 375x553 iPhone SE the primary button was finishing 7px
   past the fold, which is the one element in the hero that must not.

   This block HAS to sit after the hero rules it overrides. A media query adds
   no specificity, so ".hero .actions" inside one and ".hero .actions" outside
   it weigh exactly the same and the later wins. Written 140 lines earlier,
   this changed nothing at all and looked entirely correct doing it.

   Tightened by width rather than height: mobile browsers change their visible
   height as the address bar collapses, so a height query would retrigger
   mid-scroll. Most of the space comes back from the gaps between the hero's
   own parts rather than from type size, so the headline keeps its weight. */
@media (max-width: 400px) {
  .hero { padding: var(--s5) 0 var(--s5); }
  .hero h1 { font-size: clamp(1.95rem, 8.6vw, 2.4rem); margin-top: var(--s4); }
  .hero .lede { font-size: 1rem; margin-top: var(--s4); }
  .hero .actions { margin-top: var(--s4); }
  .hero .avail { margin-top: var(--s4); }
}

.actions { margin-top: var(--s7); display: flex; flex-wrap: wrap; gap: var(--s4); align-items: center; }
/* On a light ground the primary button is Blue 900, the heaviest weight in
   the scale. Label 12.24:1 on it, and 11.37:1 against the page. */
.btn {
  display: inline-block; padding: 14px var(--s6); border-radius: 3px;
  font-size: 0.95rem; font-weight: 600; text-decoration: none;
  background: var(--btn-bg); color: var(--btn-fg); border: 1px solid var(--btn-bg);
  transition: background-color var(--speed) ease, border-color var(--speed) ease,
              transform var(--speed) ease;
}
.btn:hover { background: var(--accent); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
/* Text link with a circled chevron, the pattern Tin Soldier uses in place of
   a second filled button. Keeps one clear primary action per screen. */
.textlink {
  font-size: 0.95rem; font-weight: 600; text-decoration: none;
  display: inline-flex; align-items: center; gap: var(--s3);
}
.textlink::after {
  content: "\203A"; display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; border-radius: 50%; border: 1.5px solid currentColor;
  font-size: 1.15rem; line-height: 1; padding-bottom: 3px;
  transition: transform var(--speed) ease;
}
.textlink:hover { text-decoration: underline; }
.textlink:hover::after { transform: translateX(3px); }

/* facts: a ruled row, not a grid of boxes */
.facts {
  margin-top: var(--s8); border-top: 1px solid var(--rule);
  display: grid; grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 780px) { .facts { grid-template-columns: repeat(4, 1fr); } }
.facts div {
  padding: var(--s5) var(--s5) var(--s5) 0;
  border-bottom: 1px solid var(--line);
  transition: background-color var(--speed) ease;
}
/* Flat fill, no radius: the strip is ruled cells, not cards, and this page
   already fixed away from everything being a rounded box once. */
/* The strip moved out of the dark panel onto the light band, so this uses the
   light tint, not the panel line colour it used when it lived in the hero. */
.facts div:hover { background: var(--tint); }
@media (min-width: 780px) {
  .facts div { border-bottom: 0; border-right: 1px solid var(--line); padding-left: var(--s5); }
  .facts div:first-child { padding-left: 0; }
  .facts div:last-child { border-right: 0; }
}
.facts b {
  display: block; font-family: var(--font-mono); font-weight: 500;
  font-variant-numeric: tabular-nums; font-size: 1.7rem; letter-spacing: -0.02em;
  color: var(--ink); line-height: 1;
}
.facts span { display: block; margin-top: var(--s2); font-size: 0.82rem; color: var(--muted); line-height: 1.4; }

/* ---------- section frame ---------- */
section { padding: var(--s8) 0; border-top: 1px solid var(--rule); }
@media (min-width: 900px) { section { padding: var(--s9) 0; } }
/* Alternating ground so the page has rhythm instead of one flat field.
   ink/surface and muted/surface are both in the measured contrast set. */

/* sticky in-section index, so the rail is not dead space on scroll */
.index { margin-top: var(--s5); border-top: 1px solid var(--line); }
/* The marker slot is always there, transparent until it is wanted, so
   nothing shifts when it fills. Hovering used to add left padding, which
   nudged the label sideways under the pointer. */
.index a {
  display: block; position: relative;
  padding: var(--s3) 0 var(--s3) var(--s4); border-bottom: 1px solid var(--line);
  font-size: 0.86rem; color: var(--muted); text-decoration: none;
}
.index a::before {
  content: ''; position: absolute; left: 0; top: 9px; bottom: 9px; width: 2px;
  background: transparent; transition: background-color var(--speed) ease;
}
.index a:hover { color: var(--accent); }
.index a:hover::before { background: var(--rule); }
.index a:active { color: var(--ink); }
/* Set by the script below as you scroll past each heading. Without
   JavaScript the links still work, they just never light up. */
.index a[aria-current] { color: var(--ink); }
.index a[aria-current]::before { background: var(--accent); }

/* pull quote: visual variety, and it earns its place by being a real line */
.pull {
  margin: var(--s7) 0; padding-left: var(--s5);
  border-left: 3px solid var(--accent);
  font-family: var(--font-display); font-weight: 700; font-size: clamp(1.2rem, 2.4vw, 1.5rem);
  line-height: 1.3; letter-spacing: -0.01em; color: var(--ink); max-width: 26ch;
}
.pull cite { display: block; margin-top: var(--s3); font: inherit;
  font-size: 0.82rem; font-family: var(--font-mono); font-style: normal;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }

.kicker { display: flex; align-items: baseline; gap: var(--s4); margin-bottom: var(--s6); }
.kicker h2 {
  font-size: clamp(1.75rem, 4vw, 2.9rem); letter-spacing: -0.02em; line-height: 1.12;
}

/* Services: a plain typographic grid, not a card. No border, no background,
   no radius: whitespace is the only divider. */
.services-lede { max-width: 56ch; color: var(--muted); font-size: 1.02rem; }
.service-grid {
  display: grid; gap: var(--s8) var(--s7); margin-top: var(--s8);
}
@media (min-width: 700px) {
  .service-grid { grid-template-columns: 1fr 1fr; }
}
.service h3 { font-size: 1.32rem; letter-spacing: -0.012em; }
.service .tagline {
  margin-top: var(--s2); margin-bottom: var(--s3);
  color: var(--accent); font-weight: 600; font-size: 0.98rem;
}
.service p:not(.tagline) { color: var(--muted); }
/* Price anchor. UNUSED FOR NOW, kept ready: see the long note in the
   services section for why the figures came off. One mono line under each
   service, the same register as the project sheet, so a price reads as a
   fact about the job rather than a sales line. */
.service .from {
  margin-top: var(--s4); padding-top: var(--s3); border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 0.78rem; letter-spacing: 0.02em; color: var(--ink);
}
.service .from b { font-weight: 600; color: var(--accent); }
/* What Rune turns down. Stated once, plainly, after the offer. Saying no in
   advance is cheaper for everyone than a polite email later. */
.fit { margin-top: var(--s8); max-width: 62ch; }
.fit .label { display: block; margin-bottom: var(--s3); }
.fit p { color: var(--muted); font-size: 0.98rem; }

/* ---------- before and after tiles ----------
   Three of the case study's numbers, pulled out of the paragraphs where a
   skimmer never finds them. Ruled cells, not cards, like the facts band.
   The old value is struck through in the muted colour above the new one, so
   the change is visible without an arrow. */
.delta {
  margin: var(--s5) 0 var(--s6); border-top: 1px solid var(--rule);
  display: grid; grid-template-columns: 1fr;
}
@media (min-width: 620px) { .delta { grid-template-columns: repeat(3, 1fr); } }
.delta div {
  padding: var(--s5) var(--s5) var(--s5) 0; border-bottom: 1px solid var(--line);
  transition: background-color var(--speed) ease;
}
.delta div:hover { background: var(--tint); }
@media (min-width: 620px) {
  .delta div { border-bottom: 0; border-right: 1px solid var(--line); padding-left: var(--s5); }
  .delta div:first-child { padding-left: 0; }
  .delta div:last-child { border-right: 0; }
  .delta { border-bottom: 1px solid var(--line); }
}
.delta .was {
  display: block; font-family: var(--font-mono); font-size: 0.78rem; color: var(--muted);
  text-decoration: line-through; text-decoration-color: var(--rule); margin-bottom: var(--s2);
}
.delta b {
  display: block; font-family: var(--font-mono); font-weight: 500;
  font-variant-numeric: tabular-nums; font-size: 1.4rem; letter-spacing: -0.02em; line-height: 1; color: var(--ink);
  white-space: nowrap;
}
.delta span:not(.was) { display: block; margin-top: var(--s2); font-size: 0.82rem; color: var(--muted); line-height: 1.4; }

/* ---------- how a job runs ----------
   Four steps in a row. The prose above already says all of it; the strip is
   for the reader who will not read the prose. Numbered in mono like the
   incident log, so the two sequences on the page share one grammar. */
.steps { list-style: none; margin: var(--s8) 0 0; padding: 0; display: grid; gap: var(--s5); border-top: 1px solid var(--rule); }
@media (min-width: 700px) { .steps { grid-template-columns: repeat(2, 1fr); gap: 0; } }
@media (min-width: 980px) { .steps { grid-template-columns: repeat(4, 1fr); } }
.steps li {
  padding: var(--s5) var(--s5) var(--s5) 0; border-bottom: 1px solid var(--line);
  transition: background-color var(--speed) ease;
}
.steps li:hover { background: var(--tint); }
@media (min-width: 700px) {
  .steps li { padding-left: var(--s5); border-right: 1px solid var(--line); }
  .steps li:first-child { padding-left: 0; }
  .steps li:nth-child(2n) { border-right: 0; }
}
@media (min-width: 980px) {
  .steps li:nth-child(2n) { border-right: 1px solid var(--line); }
  .steps li:last-child { border-right: 0; }
}
.steps .n { font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); letter-spacing: 0.1em; display: block; margin-bottom: var(--s2); }
.steps h3 { font-size: 1.12rem; letter-spacing: -0.01em; line-height: 1.25; }
.steps p { margin-top: var(--s2); font-size: 0.93rem; color: var(--muted); }

/* asymmetric two-column: label rail + prose */
.split { display: grid; gap: var(--s5); }
@media (min-width: 980px) {
  .split { grid-template-columns: 224px minmax(0, 1fr); gap: var(--s7); align-items: start; }
  .rail { position: sticky; top: 92px; }
}
.rail .label { display: block; margin-bottom: var(--s3); }


/* ---------- project sheet (case meta) ---------- */
.sheet { border-top: 1px solid var(--rule); margin: var(--s6) 0 var(--s7); }
.sheet div {
  display: grid; grid-template-columns: 1fr; gap: 2px;
  padding: var(--s3) 0; border-bottom: 1px solid var(--line);
}
@media (min-width: 620px) {
  .sheet div { grid-template-columns: 150px minmax(0, 1fr); gap: var(--s5); align-items: baseline; }
}
/* Inside the narrow rail there is no room for two columns, so stack the
   term above its value regardless of viewport width. */
.rail .sheet div { grid-template-columns: 1fr; gap: 2px; }
.sheet dt {
  font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--muted);
}
.sheet dd { margin: 0; font-size: 0.98rem; }

.flag {
  display: inline-block; font-family: var(--font-mono); font-size: 0.68rem;
  letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--accent); border: 1px solid var(--accent);
  padding: 4px 10px; border-radius: 2px; margin-bottom: var(--s5);
}

/* ---------- prose ---------- */
/* 62ch, not 68. ch is the width of a zero, about 1.3 average characters, so
   68ch was running to 85-plus characters a line. 62ch lands near 78, which
   is the top of the readable range for 17px. Not narrower: the rail already
   takes 224px and the incident log needs room for its own sentences. */
.prose { max-width: 62ch; }
/* Closing statement. Every other section is a label rail beside a prose column,
   four of them in a row, which had started to read as one component stamped out
   repeatedly. This one drops the rail and sets two columns across the full shell,
   so the page changes shape once before the contact panel. Single column below
   980px, where two would be unreadable. */
.prose.statement { max-width: none; }
.prose.statement p { font-size: 1.06rem; }
@media (min-width: 980px) {
  .prose.statement { columns: 2; column-gap: var(--s8); }
  /* .prose spaces children with margin-top, which would push the first
     paragraph of the second column down out of alignment. */
  .prose.statement > * + * { margin-top: 0; }
  .prose.statement p { margin: 0 0 1.15em; break-inside: avoid; }
  .prose.statement p:last-child { margin-bottom: 0; }
}
.prose > * + * { margin-top: 1.15em; }
.prose b, .prose strong { font-weight: 600; }
/* Headings carry the scan. NN/g's eyetracking on the layer-cake pattern says
   the two things that make it work are visual distinctiveness and generous
   space above, so both are pushed harder than a normal type step. */
.prose h3 {
  font-size: 1.45rem; letter-spacing: -0.012em; line-height: 1.2;
  margin-top: 2.8em; padding-bottom: var(--s3); border-bottom: 1px solid var(--rule);
}
.prose h3:first-child { margin-top: 0; }

.list { list-style: none; padding: 0; margin: 0; }
.list li {
  padding: var(--s3) 0 var(--s3) var(--s5);
  border-bottom: 1px solid var(--line); color: var(--muted); position: relative;
}
.list li:first-child { border-top: 1px solid var(--line); }
.list li::before {
  content: ""; position: absolute; left: 0; top: 1.35em;
  width: 7px; height: 1px; background: var(--accent);
}
.list li b { color: var(--ink); font-weight: 600; }

/* ---------- incident log (the bugs) ---------- */
.log { margin-top: var(--s5); }
/* The log is six entries a reader scans one at a time, so each one answers
   to the pointer rather than the block holding still. Same marker slot as
   the case index: it is there all along and only takes a colour, so hovering
   never moves a word. */
.incident {
  padding: var(--s6) 0 var(--s6) var(--s5); border-top: 1px solid var(--line);
  position: relative;
}
.incident::before {
  content: ''; position: absolute; left: 0; top: var(--s6); bottom: var(--s6);
  width: 2px; background: transparent; transition: background-color var(--speed) ease;
}
.incident:hover::before { background: var(--accent); }
.incident:last-child { border-bottom: 1px solid var(--line); }
.incident .n {
  font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent);
  letter-spacing: 0.1em; display: block; margin-bottom: var(--s2);
}
.incident h4 {
  font-family: var(--font-display); font-weight: 700; font-size: 1.15rem;
  margin: 0 0 var(--s3); letter-spacing: -0.005em;
}
.incident p { color: var(--muted); }
.incident p + p { margin-top: var(--s3); }
/* The six entries were 2,412px of a 14,309px page, 17%, and a reader who was
   never going to read all six still had to scroll past all six. As <details>
   the six headlines stay in view and the reader picks which to open, which is
   a better version of the same argument: nothing is hidden, it is indexed.
   Native element, so it works with JavaScript off, and Google indexes closed
   content. 01 is left open so it is obvious the other five do the same. */
.incident > summary {
  cursor: pointer; list-style: none; position: relative; padding-right: var(--s6);
}
.incident > summary::-webkit-details-marker { display: none; }
.incident > summary:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px;
}
.incident > summary h4 { margin-bottom: 0; }
/* .n sits inside the h4 now, so summary holds one heading element and stays
   valid. It has to shed the h4 weight to look the way it did as a sibling. */
.incident > summary .n { font-weight: 400; }
/* Same chevron the masthead dropdown uses, so the page has one way of saying
   "this opens" instead of two. It sits in the padding reserved above, so
   rotating it never reflows the title beside it. */
.incident > summary::after {
  content: ''; position: absolute; right: 2px; top: 50%;
  width: 6px; height: 6px; margin-top: -6px;
  border-right: 1.5px solid var(--accent); border-bottom: 1.5px solid var(--accent);
  transform: rotate(45deg); transition: transform var(--speed) ease;
}
.incident[open] > summary::after { transform: rotate(-135deg); margin-top: -2px; }
.incident[open]::before { background: var(--accent); }
.incident > summary + p { margin-top: var(--s3); }
.incident .fix {
  color: var(--ink); border-left: 2px solid var(--accent);
  padding-left: var(--s4); margin-top: var(--s4);
}

/* ---------- panels, used sparingly ---------- */
figure { margin: var(--s7) 0; }
figure img {
  display: block; width: 100%; height: auto;
  border: 1px solid var(--rule); border-radius: 2px;
}
figcaption {
  margin-top: var(--s3); padding-left: var(--s4);
  border-left: 2px solid var(--line);
  font-size: 0.87rem; color: var(--muted); max-width: 60ch;
}

.note {
  background: var(--surface); border: 1px solid var(--line);
  border-left: 3px solid var(--accent); border-radius: 2px;
  padding: var(--s5); margin-top: var(--s5);
}
.note .label { display: block; margin-bottom: var(--s2); color: var(--accent); }
.note p { color: var(--muted); font-size: 0.95rem; }
/* p carries margin:0 globally, so a note that grew to two paragraphs ran
   them together into one block. */
.note p + p { margin-top: var(--s3); }
.note p b { color: var(--ink); }

/* ---------- contact ---------- */
/* Background comes from .panel, which is on the same element. Setting it
   here too would win on source order and silently undo the panel. */
.contact { border-top: 1px solid var(--rule); padding: var(--s8) 0; }
@media (min-width: 900px) { .contact { padding: var(--s9) 0; } }
.contact h2 { font-size: clamp(1.9rem, 4.4vw, 3.1rem); letter-spacing: -0.022em; max-width: 16ch; line-height: 1.08; }
.contact .measure { margin-top: var(--s5); color: var(--muted); }
.card {
  margin-top: var(--s7); border-top: 1px solid var(--rule);
  display: grid; gap: 0;
}
@media (min-width: 720px) { .card { grid-template-columns: repeat(2, 1fr); } }
.card div { padding: var(--s5) 0; border-bottom: 1px solid var(--line); }
@media (min-width: 720px) {
  .card div + div { padding-left: var(--s6); border-left: 1px solid var(--line); }
}
.card .label { display: block; margin-bottom: var(--s2); }
.card a, .card span { font-size: 1.05rem; }
.card a { color: var(--ink); text-decoration-color: var(--accent); }
.card a:hover { color: var(--accent); }

/* ---------- contact: use the width ----------
   The band is 1044px wide inside the shell and the form stops at 62ch, so
   476px sat empty beside it before the email row jumped back to full width.
   This moves the email row up into that gap. Nothing new is written: it
   fills the space with content the section already had, and takes 161px of
   height off the bottom of a 14,470px page.

   Measured at 1920px, before and after:
     content width    568px -> 1044px
     unused           476px ->    0px
     section height  1387px -> 1226px

   Two things here are load-bearing and neither is obvious:
   - .contact > .shell, not .contact .shell. The footer has its own .shell
     inside #contact, and the descendant version turns the footer into a
     two-column grid as well.
   - .card div + div picks up a left border and 32px of padding at 720px and
     up. That is right side by side and wrong stacked, so it is reset here,
     because the card is one column in this rail. */
@media (min-width: 900px) {
  .contact > .shell {
    display: grid;
    grid-template-columns: 62ch minmax(0, 1fr);
    gap: 0 var(--s7);
    align-items: start;
  }
  .contact h2        { grid-column: 1 / -1; grid-row: 1; }
  .contact .measure  { grid-column: 1 / -1; grid-row: 2; }
  .contact .enquiry  { grid-column: 1; grid-row: 3; }
  .contact .fallback { grid-column: 1; grid-row: 4; }
  .contact .card {
    grid-column: 2; grid-row: 3;
    margin-top: var(--s6);
    grid-template-columns: 1fr;
  }
  .contact .card div + div { padding-left: 0; border-left: 0; }
}

/* ---------- the enquiry form ----------
   Three fields that compose the email the copy above already asks for: what
   you sell, what isn't working, where to reply. There is no backend and no
   third party: submit opens the visitor's own mail app with the message
   written, so the "no external requests" line in the footer stays true and
   nobody stares at a blank email wondering what to put. Without JavaScript
   the form is not shown at all and the plain email link does the job, which
   is the honest fallback rather than a form that silently goes nowhere.
   Fields sit on the surface step of the deep ground, with the hairline as
   border and the accent as the focus ring, so they read as part of the block
   rather than white boxes pasted on. */
.enquiry { display: none; }
.js .enquiry {
  display: grid; gap: var(--s4); margin-top: var(--s6); max-width: 62ch;
}
.enquiry label { display: block; }
.enquiry .label { display: block; margin-bottom: var(--s2); }
.enquiry input, .enquiry textarea {
  width: 100%; display: block; font: inherit; font-size: 1rem; line-height: 1.5;
  color: var(--ink); background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
  padding: 12px var(--s4); margin: 0;
  transition: border-color var(--speed) ease, background-color var(--speed) ease;
}
.enquiry input::placeholder, .enquiry textarea::placeholder { color: var(--muted); opacity: .8; }
.enquiry input:hover, .enquiry textarea:hover { border-color: var(--accent); }
.enquiry input:focus-visible, .enquiry textarea:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent);
}
.enquiry textarea { resize: vertical; min-height: 7.5em; }
.enquiry .actions { margin-top: var(--s2); }
.enquiry button.btn { cursor: pointer; font-family: inherit; }
.enquiry .small { font-size: 0.83rem; color: var(--muted); max-width: 52ch; }

/* Copy button beside the email address. Written into the markup with the
   hidden attribute and only revealed by the script once it has checked the
   browser actually has a clipboard to write to, so nobody is ever offered a
   button that does nothing. Most visitors are reading this on a machine that
   is not the one they will write the email from. */
.copybtn {
  margin-left: var(--s3); vertical-align: middle;
  font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.1em;
  text-transform: uppercase; cursor: pointer;
  color: var(--muted); background: none;
  border: 1px solid var(--line); border-radius: 2px; padding: 5px 9px;
  transition: color var(--speed) ease, border-color var(--speed) ease;
}
.copybtn:hover { color: var(--ink); border-color: var(--rule); }
.copybtn[data-done] { color: var(--accent); border-color: var(--accent); }
/* The plain "Send an email" button is the JS-off fallback. With the form
   showing, two buttons that both send an email is one too many, so it goes;
   the address itself stays in the card below for anyone who would rather
   write their own. */
.js .contact .fallback { display: none; }

footer { padding: var(--s6) 0 var(--s8); }
/* Footer navigation. A long page needs a way back up that is not the sticky
   bar, and a search engine reads a link list here as the site's shape. */
.footnav { list-style: none; margin: 0 0 var(--s6); padding: var(--s5) 0 0; border-top: 1px solid var(--rule);
  display: flex; flex-wrap: wrap; gap: var(--s3) var(--s6); font-size: 0.86rem; }
.footnav a { color: var(--muted); text-decoration: none; min-height: 32px; display: inline-flex; align-items: center; }
.footnav a:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 5px; }
.colophon { font-size: 0.83rem; color: var(--muted); max-width: 74ch; line-height: 1.6; }

/* ---------- motion ----------
   Scoped to .js so that with JavaScript off the content is simply visible,
   rather than stuck at opacity 0 waiting for an observer that never runs. */
.js .reveal { opacity: 0; transform: translateY(10px); transition: opacity .5s ease, transform .5s ease; }
.js .reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .js .reveal { opacity: 1; transform: none; }
}

/* Hero entrance. Runs once on load rather than on scroll, since the hero is
   the first thing on the page and nothing needs to wait to be observed.
   Pure CSS, not gated behind .js: @keyframes plays with no script at all, so
   unlike .reveal above there is no JS-off case to guard against here. If a
   browser doesn't support the animation property it is simply ignored and
   everything renders at its normal opacity, never stuck invisible. */
@keyframes hero-in { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
.hero .place, .hero h1, .hero .lede, .hero .actions, .hero .avail,
.factband .facts {
  animation: hero-in .6s ease both;
}
.hero .place      { animation-delay: .04s; }
.hero h1          { animation-delay: .08s; }
.hero .lede       { animation-delay: .18s; }
.hero .actions    { animation-delay: .26s; }
.hero .avail      { animation-delay: .32s; }
.factband .facts  { animation-delay: .38s; }
@media (prefers-reduced-motion: reduce) {
  .hero .place, .hero h1, .hero .lede, .hero .actions, .hero .avail,
  .factband .facts { animation: none; }
}

/* ---------- the dark panel ----------
   Blue 900, the heaviest weight in the scale. Only two things on the page
   get it: the hero and the contact block. They are 12,000px apart, so they
   never compete for the eye. Everything inside inverts to the panel tokens,
   all of which were measured before use. */
.panel {
  background: var(--panel-bg);
  color: var(--panel-fg);
}
.panel h1, .panel h2, .panel h3 { color: var(--panel-fg); }
.panel .label { color: var(--panel-muted); }
.panel .sub, .panel .measure, .panel p { color: var(--panel-muted); }
.panel .lede { color: var(--panel-fg); }
.panel .sub b, .panel b, .panel strong { color: var(--panel-fg); }
.panel a { color: var(--panel-accent); }
.panel a:hover { color: var(--panel-fg); }
.panel a:focus-visible { outline-color: var(--panel-accent); }
.panel .place { color: var(--panel-accent); }
.panel h1 .payoff { color: var(--panel-accent); }
.panel .avail .dot { background: var(--panel-accent); }
.panel .btn {
  background: var(--panel-btn-bg); color: var(--panel-btn-fg);
  border-color: var(--panel-btn-bg);
}
.panel .btn:hover { background: var(--panel-accent); border-color: var(--panel-accent); color: var(--panel-btn-fg); }
.panel .textlink { color: var(--panel-accent); }
.panel .textlink:hover { color: var(--panel-fg); }
.panel .facts { border-top-color: var(--panel-line); }
.panel .facts div { border-color: var(--panel-line); }
.panel .facts b { color: var(--panel-fg); }
.panel .facts span { color: var(--panel-muted); }
.panel .card { border-top-color: var(--panel-line); }
.panel .card div { border-color: var(--panel-line); }
.panel .card a, .panel .card span { color: var(--panel-fg); }
.panel .card a:hover { color: var(--panel-accent); }
.panel .colophon { color: var(--panel-muted); }
.panel .colophon a { color: var(--panel-muted); }
.panel .colophon a:hover { color: var(--panel-accent); }


/* ==========================================================================
   THE RAMP. The page descends: a dark nav, the lightest band at the hero,
   five steps down through the body, and the contact block closing on the
   same dark blue the nav opened with.

   Each band sets its own tokens rather than its own selectors. Every rule on
   this page reads --ink, --muted, --line, --rule and --accent, and custom
   properties inherit, so a band that redefines them recolours everything
   inside it automatically. Adding a band means adding one rule here.

   .band carries what every light step shares. The per-step rules carry only
   what has to change: the ground, and the hairlines, which have to darken
   with it or they disappear into their own background.
   ========================================================================== */
/* 2026-09-08: the ramp was five saturated mid-blues, 16 points apart, and the
   two case studies (most of the page) sat on the two deepest of them at
   5.5:1. Coherent, and tiring to read 3,000 words on. The benchmark showed
   every credible small studio reads on near-white with colour kept for the
   edges. So: colour lives in the bookends and the hero field, and READING
   HAPPENS ON PAPER. Two near-whites alternate down the body, and the last
   band steps one shade deeper so the eye is ready for the dark contact block.
   Hairlines are two and four steps below their ground so they stay visible;
   the note surface is one step deeper than the ground it sits on, never
   lighter, so a callout reads as set into the page rather than floating. */
.band {
  --ink: #1C1714;
  --muted: #4E4038;
  --accent: #9C3A0C;
  --accent-soft: #F3DCC8;
}
.f1 { background: #F7F0EE; --line: #E4D5D1; --rule: #CCB6B0; --tint: #F2E8E4; --surface: #FCF8F6; --raised: #FDFAF7; }
.f2 { background: #FCF8F6; --line: #EADDD9; --rule: #D4BFB8; --tint: #F7F0EE; --surface: #F7F0EE; --raised: #FFFFFF; }
.f3 { background: #FDFAF7; --line: #EFE2DE; --rule: #DAC6BF; --tint: #F9F4F2; --surface: #F9F4F2; --raised: #FFFFFF; }
.f4 { background: #FCF8F6; --line: #EADDD9; --rule: #D4BFB8; --tint: #F7F0EE; --surface: #F7F0EE; --raised: #FFFFFF; }
.f5 { background: #F2E8E4; --line: #DECCC5; --rule: #C3ADA4; --tint: #EDE0DB; --surface: #F7F0EE; --raised: #FCF8F6; }

/* The two dark bookends, the same plum as the hero. Near-white type on deep
   plum, with amber for links and the button, which is the one control on the
   page allowed to shout. --btn-bg and --btn-fg are the tokens .btn already
   reads, so the button needs no selector of its own here. */
.deep {
  --deep-bg: #241028;
  background: var(--deep-bg);
  color: #F9F2FA;
  --ink: #F9F2FA;
  --muted: #CBB2D0;
  --line: #3D2242;
  --rule: #553458;
  --accent: #FFB067;
  --accent-soft: #2E1633;
  --surface: #2C1531;
  --raised: #331A38;
  --btn-bg: #C24A0B;
  --btn-fg: #FFFFFF;
}
.deep h1, .deep h2, .deep h3 { color: var(--ink); }
.deep a { color: var(--accent); }
.deep a:hover { color: var(--ink); }
/* .deep a is (0,2,0) and .btn is only (0,1,0), so without this the link
   colour wins and the button label goes pale amber on orange. Caught by the
   sweep, not by eye. */
.deep .btn { color: var(--btn-fg); }
.deep .btn:hover { background: var(--accent); border-color: var(--accent); color: #241028; }
.panel .btn:hover { background: var(--panel-accent); border-color: var(--panel-accent); color: #241028; }
/* The wordmark is a white PNG. Everywhere else it gets brightness(0) to sit
   on a light ground; on the dark nav it goes back to being what it already
   is, which is why no second asset was ever needed. */
.deep .wordmark .mark { filter: none; }

/* ==========================================================================
   THE WARM MARK. One accent, five uses, all of them status or attention:
   the availability dot, the "See the work" arrow, the "In progress" flag,
   and the two quote/callout rules.

   The page is otherwise a single hue from top to bottom, which is coherent
   and can read as flat. This is the smallest intervention that fixes that:
   nothing structural moves, no ground changes, and the accent stays rare
   enough to still mean something. Text stays blue; the warm is a marker,
   never a second reading colour.

   Placed last so it outranks the .panel and band rules it overrides, which
   is the mistake made four times already this session. */
/* Now that the page's own accent IS the amber, these markers and the reading
   accent are the same family rather than two ideas. The value still differs
   by ground: text needs 4.5:1 and a graphic needs 3:1, so the marker can sit
   brighter on the plum than any word could. */
.warmmark {
  --warm: #9C3A0C;
}
.warmmark .panel .avail .dot, .warmmark .avail .dot { background: var(--warm); }
.warmmark .textlink::after { color: var(--warm); border-color: var(--warm); }
.warmmark .flag { border-color: var(--warm); }
.warmmark .pull { border-left-color: var(--warm); }
.warmmark .note { border-left-color: var(--warm); }

/* The repeating one: a short rule before every section label, 13 times down
   the page. Small enough to stay quiet, frequent enough to register as a
   decision rather than an accident.

   On the plum the darker amber goes muddy, so those get a lifted value
   instead. One accent, two values, chosen by what it has to sit on. */
.warmmark { --warm-lift: #FFB067; }
.warmmark .label::before {
  content: ''; display: inline-block; vertical-align: middle;
  width: 14px; height: 2px; margin-right: 9px; margin-top: -2px;
  background: var(--warm);
}
.warmmark .deep .label::before,
.warmmark .panel .label::before { background: var(--warm-lift); }

/* ==========================================================================
   LIFT, 2026-09-14. Motion and hover borrowed from momac.co.nz, the Rangiora
   agency site Josh pointed at: a headline whose last words rotate, a
   highlighter stroke under the hook, a slow ticker of the systems list,
   services that answer to the pointer and link to where they were built,
   reveals that arrive one cell at a time, and a storefront frame that tilts
   toward the cursor.

   Kept inside the page's own rules. Nothing loads from anywhere else, each
   piece is CSS plus a few lines of script, and under prefers-reduced-motion
   everything here that moves a WORD or an IMAGE stops. The hero drift is the
   one standing exception and that decision is recorded above. Every piece
   is its own class, so any one of them can be dropped without touching the
   rest. Placed last so it outranks what it overrides.
   ========================================================================== */

/* The rotating line. MoMac runs "Providing" and then one service at a time,
   2.5 seconds each. Same idea: "Rune builds" and then one of five things it
   does, all of them true and all of them already on this page. The words
   are stacked in one grid cell, so the box is always as wide as the widest
   word and nothing below it ever shifts as the words change. A screen
   reader gets the whole list once, as a sentence, and never hears the
   rotation. */
.hero h1 .payoff { max-width: none; }
.rotator { display: inline-grid; vertical-align: top; text-align: left; }
.rotator > span {
  grid-area: 1 / 1; white-space: nowrap;
  opacity: 0; transform: translateY(0.45em);
  animation: rotate-word 12.5s ease-in-out infinite;
}
.rotator > span:nth-child(2) { animation-delay: 2.5s; }
.rotator > span:nth-child(3) { animation-delay: 5s; }
.rotator > span:nth-child(4) { animation-delay: 7.5s; }
.rotator > span:nth-child(5) { animation-delay: 10s; }
/* Five words share one 12.5s cycle, so each owns 20% of it: rise in over the
   first 4%, hold, leave upward over the last 4%. Every keyframe names both
   opacity and translateY so the browser interpolates the value, never the
   matrix; see the orbit note above for what happens otherwise. */
@keyframes rotate-word {
  0%   { opacity: 0; transform: translateY(0.45em); }
  4%   { opacity: 1; transform: translateY(0); }
  17%  { opacity: 1; transform: translateY(0); }
  21%  { opacity: 0; transform: translateY(-0.45em); }
  100% { opacity: 0; transform: translateY(-0.45em); }
}
.sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap;
}


/* The ticker. The systems list used to be a static row of chips; now it
   drifts left at about 40 seconds a lap and pauses under the pointer. Two
   identical rows sit side by side and the track moves exactly half its own
   width, so the join is invisible. The second row is hidden from assistive
   tech, which reads the list once. Faded at both edges so the chips enter
   and leave rather than being cut off. */
.ticker {
  overflow: hidden; margin-top: var(--s2);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
}
.ticker .track { display: flex; width: max-content; animation: ticker 40s linear infinite; }
.ticker .stack { flex-wrap: nowrap; margin-top: 0; padding-right: var(--s2); }
.ticker:hover .track, .ticker:focus-within .track { animation-play-state: paused; }
@keyframes ticker {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* Services answer to the pointer. Same marker slot the case index and the
   incident log use: always there, transparent until hovered, so nothing
   moves. Each service also ends with a link to the part of a case study
   where that work is actually visible, which is the useful half of this. */
.service { position: relative; padding-left: var(--s5); }
.service::before {
  content: ''; position: absolute; left: 0; top: 0.3em; bottom: 0; width: 2px;
  background: transparent; transition: background-color var(--speed) ease;
}
.service:hover::before { background: var(--accent); }
.service .where { margin-top: var(--s4); font-size: 0.9rem; }
.service:hover .where { text-decoration: underline; }
.service:hover .where::after { transform: translateX(3px); }

/* Reveals arrive one cell at a time. The section still fades in as one
   block; inside it the four services and the four steps follow at 80ms
   intervals, which is MoMac's fade-in-from-bottom without the plugin.
   Scoped to .js like the reveal itself, so with scripting off everything is
   simply visible. */
.js .reveal .service, .js .reveal .steps li {
  opacity: 0; transform: translateY(12px);
  transition: opacity .5s ease, transform .5s ease, background-color var(--speed) ease;
}
.js .reveal.in .service, .js .reveal.in .steps li { opacity: 1; transform: none; }
.js .reveal .service:nth-child(2), .js .reveal .steps li:nth-child(2) { transition-delay: .08s; }
.js .reveal .service:nth-child(3), .js .reveal .steps li:nth-child(3) { transition-delay: .16s; }
.js .reveal .service:nth-child(4), .js .reveal .steps li:nth-child(4) { transition-delay: .24s; }

/* Buttons lift a pixel and cast a shadow under the pointer, and press back
   down on click. The colour change they already had stays. */
.btn {
  transition: background-color var(--speed) ease, border-color var(--speed) ease,
              transform var(--speed) ease, box-shadow var(--speed) ease;
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0, 0, 0, .22); }
.btn:active { transform: translateY(1px); box-shadow: none; }

/* The frame used to tilt toward the cursor and the picture inside it grew
   slightly under the pointer (MoMac's image zoom). Both switched off on
   1 Nov 2026: the photo of Josh stays still. The script no longer writes
   --rx or --ry. */
.proof .frame { transform: none; }
.proof .frame img { transition: none; }
.proof:hover .frame img { transform: none; }

/* The bar picks up a shadow once the page has scrolled under it, so it reads
   as sitting over the page rather than being the top of it. */
.masthead { transition: box-shadow var(--speed) ease; }
.masthead.scrolled { box-shadow: 0 8px 24px rgba(0, 0, 0, .28); }

/* LIGHT NAV BAR, variant for Josh to compare. The bar leaves the plum and
   sits on the first paper step, so the hero starts under it as its own
   field, the way MoMac runs a white bar over its photo. The wordmark goes
   back to black through the brightness(0) rule it already had. The
   dropdown and the phone menu read --deep-bg, which only .deep defines,
   so it is set here or they would paint transparent. */
.masthead { background: #F7F0EE; --deep-bg: #FCF8F6; --line: #E4D5D1; --rule: #CCB6B0; }
.masthead.scrolled { box-shadow: 0 6px 20px rgba(28, 23, 20, .14); }

/* Reduced motion: every piece above that moves a word or an image stops.
   The rotator shows its first word and stays there, the stroke is drawn
   already, the ticker becomes the wrapped row it replaced, the cells arrive
   with their section, and the frame sits flat. */
@media (prefers-reduced-motion: reduce) {
  .rotator > span { animation: none; }
  .rotator > span:first-child { opacity: 1; transform: none; }
  .ticker { -webkit-mask-image: none; mask-image: none; }
  .ticker .track { animation: none; display: block; width: auto; }
  .ticker .stack { flex-wrap: wrap; padding-right: 0; }
  .ticker .stack[aria-hidden] { display: none; }
  .js .reveal .service, .js .reveal .steps li { opacity: 1; transform: none; transition-delay: 0s; }
  .btn:hover { transform: none; box-shadow: none; }
  .proof .frame { transform: none; transition: none; }
  .proof:hover .frame img { transform: none; }
}


/* ==========================================================================
   THREE PAGES, 2026-09-14. The two case studies moved to their own URLs and
   the homepage became short: hero, facts, services, two case teasers, how
   Rune works, contact. What follows is the little that needed adding.
   ========================================================================== */

/* A case page opens on its kicker, so that kicker carries the page's one h1,
   set exactly as the h2 the homepage uses for the same slot. */
.kicker h1 { font-size: clamp(1.75rem, 4vw, 2.9rem); letter-spacing: -0.02em; line-height: 1.12; }
/* Room above the first band on a case page: there is no hero, so the section's
   own top padding is all the breathing space it gets under the bar. */
.casepage main > section:first-child { border-top: 0; }

/* The two teasers on the homepage. Two full-width rows: a picture in the same
   hairline frame the hero uses, and beside it a label, a heading that is the
   link, one paragraph, three ruled numbers, a text link. The second row is
   mirrored so the section reads as a spread, not a list. The picture grows
   very slightly under the pointer, the same as the hero photo, and the whole
   row is the hover target so it works from the words too.

   No short warm rule before the labels in this section. Everywhere else it
   is a mark; against these full-width rows it read as a dash, and Josh read
   it as one on 2026-09-14. #projects outranks the .warmmark rule that draws
   it, so this is the one place it is off. */
#projects .label::before { display: none; }
.cases { display: grid; gap: var(--s8); margin-top: var(--s7); }
.case { display: grid; gap: var(--s5); align-items: center; }
@media (min-width: 900px) {
  .case { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--s7); }
  .case:nth-child(even) .case-media { order: 2; }
}
.case-media {
  display: block; overflow: hidden; border-radius: 3px;
  border: 1px solid var(--rule); background: var(--surface);
}
.case-media img { display: block; width: 100%; height: auto; transition: transform .8s ease; }
.case:hover .case-media img { transform: scale(1.035); }
/* Label and flag share one line, so both teasers' headings sit level. */
.case .label { display: inline-block; vertical-align: middle; }
.case .flag { margin: 0 0 0 var(--s3); vertical-align: middle; }
.case h3 { font-size: clamp(1.6rem, 2.6vw, 2.1rem); letter-spacing: -0.018em; line-height: 1.12; margin-top: var(--s3); }
.case h3 a { color: var(--ink); text-decoration: none; }
.case h3 a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
.case-body > p { margin-top: var(--s3); color: var(--muted); }
.case .facts { margin-top: var(--s5); }
.case .facts.three { grid-template-columns: repeat(3, 1fr); }
.case .facts.three div { border-bottom: 0; border-right: 1px solid var(--line); padding-left: var(--s4); }
.case .facts.three div:first-child { padding-left: 0; }
.case .facts.three div:last-child { border-right: 0; }
.case .facts b { font-size: 1.25rem; }
.case .facts span { font-size: 0.8rem; }
/* Three cells across a 375px phone squeezed "425 of 425" onto two lines, so
   below 480px the triplet stacks and the rules turn horizontal. */
@media (max-width: 479px) {
  .case .facts.three { grid-template-columns: 1fr; }
  .case .facts.three div { border-right: 0; border-bottom: 1px solid var(--line); padding-left: 0; }
  .case .facts.three div:last-child { border-bottom: 0; }
}
.case .textlink { margin-top: var(--s5); }
/* Teaser cells arrive with the section, one after another, like the services. */
.js .reveal .case { opacity: 0; transform: translateY(12px); transition: opacity .5s ease, transform .5s ease; }
.js .reveal.in .case { opacity: 1; transform: none; }
.js .reveal .case:nth-child(2) { transition-delay: .1s; }
.js .reveal .case:nth-child(3) { transition-delay: .2s; }
@media (prefers-reduced-motion: reduce) {
  .js .reveal .case { opacity: 1; transform: none; transition-delay: 0s; }
  .case:hover .case-media img { transform: none; }
}

/* The photo in the hero is portrait, 4:5. The frame it sits in was drawn for a
   landscape screenshot and needs nothing changed: the column is 420px wide
   from 980px up, so the photo is 420 by 525. Below that the figure used to be
   allowed 520px wide, which for a portrait would be 650px tall on a tablet,
   so the portrait is capped narrower. */
.proof.portrait { max-width: 360px; }
@media (min-width: 980px) { .proof.portrait { max-width: none; } }
.proof.portrait figcaption { margin-top: var(--s3); }
