﻿/*
  Direction B вЂ” Editorial.

  Cold grey ground, black and grey text, one sharp grotesque, a very large
  tight headline, and air. No accent colour anywhere in the text: the interest
  comes from type scale, structure and the background, not from decoration.

  Constraints that still hold from the security review:
  - every rule lives in this file, so the CSP can be style-src 'self'
  - the font is self-hosted, so font-src stays 'self'
  - the grain is a real PNG, so img-src needs no data: allowance
  - no motion of any kind under prefers-reduced-motion

  Motion follows the rules in the design skill: custom curves rather than the
  weak built-ins, everything interactive under 300ms, ease-out on entrances,
  never ease-in, and nothing ever animates from scale(0).
*/

/* ---------- font ---------- */

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/assets/inter.woff2") format("woff2");
  /* Matched to the fallback so swapping in does not reflow the page. */
  size-adjust: 100%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* ---------- tokens ---------- */

:root {
  color-scheme: light;

  --bg: #eceef0;
  --bg-raised: #f6f7f8;
  --line: #d3d7dd;
  --line-soft: #e0e3e8;
  --fg: #0b0c0e;
  /* Muted text is most of the reading on this site, so it clears 7:1 rather
     than the 4.5:1 the standard asks for. #4a5058 landed on exactly 7.00 and
     the suite failed it at 6.9991 - correctly, since a target you sit exactly
     on is a target you fall off. #464c54 gave 7.46 against a bare --bg.

     Darkened to #363b42 on 2026-08-27, because a bare --bg is no longer the
     case that binds. Ledes now sit over the background video, and sampling
     the rendered pixels under the glyph ink - 61 frames, four widths - put
     #464c54 at 4.48:1 in the worst frame (1280px, t=26s, ground
     rgb(180,187,197)): under the standard's 4.5, not merely under this
     site's 7.

     Measured against that same frame, not asserted:
       #464c54   4.48 over the worst frame    7.45 over a bare --bg
       #363b42   5.83                         9.70   <- shipped
       #292e35   7.06                        11.75

     So #363b42 buys back the standard with headroom and does NOT reach 7:1
     over video. Only #292e35 does, and at 11.75 against the ground it sits
     so close to body text (#0b0c0e, 16.82) that muted and body stop reading
     as two weights. That trade is a palette decision, deliberately not taken
     here. The full method is in the comment on .bg-video-wash. */
  --fg-muted: #363b42;
  /*
    One hue, with two jobs.

    An earlier version of this file had four sources of colour in the background
    alone - a blue accent, a violet accent, and two coloured light passes - and
    removing all of them was right. What it left behind was a page where the
    only coloured object was the figure, which read as an accident rather than a
    decision, and a page long enough that the eye had nothing to navigate by.

    So the colour comes back in exactly one place: things that do something.
    Buttons, links, focus, the controls in the form and the checklist. Headings,
    rules, card edges and the light passes stay grey - the
    rule that black is only for text is untouched, and nothing that cannot be
    pressed is coloured.

    The hue is the figure's own. .figure--blue multiplies the finished grey by
    0.34 0.68 1.60, which normalises to roughly 222deg, and both values below sit
    on that hue. They are dark enough to clear this site's own 7:1 rule against
    --bg rather than the 4.5:1 the standard asks for, with headroom rather than
    on the nose: this file already records a muted grey that measured 7.00 and
    failed the suite at 6.9991, correctly, since a target you sit exactly on is
    a target you fall off. #1440b4 measures 7.45 and #1138a0 measures 8.61, and
    because the button is --bg on --accent the same figure covers the label.
  */
  --accent: #1440b4;
  --accent-strong: #1138a0;
  /* 12% of the hue, for the ground behind something the pointer is on. Never
     behind text that has to be read at length. */
  --accent-wash: color-mix(in srgb, var(--accent) 10%, transparent);
  --tint: rgba(11, 12, 14, 0.045);
  --sweep: rgba(132, 138, 148, 0.42);
  --sweep-far: rgba(112, 118, 128, 0.3);
  --beam: rgba(198, 202, 210, 0.5);
  --grain: 0.5;

  /*
    Glass.

    Panels on this site sit over a moving figure, so they are surfaces rather
    than holes: they let the ground through, blur it so it cannot compete with
    the text, and carry a rim that is bright where a curved edge would catch
    the light and absent across the middle of each side.

    The rim is two stops because it is drawn as a gradient border, and the
    gradient is the whole effect - a flat 1px line reads as a box, the same line
    fading out at its midpoints reads as something with a thickness.
  */
  /*
    Almost nothing.

    The service cards used to be filled panels, and filled panels are what the
    owner was looking at when he said the background behind the text stood out.
    He was right: a rectangle over a moving figure is a rectangle first and a
    card second. What defines a card now is its edge and its padding, which is
    all a card ever needed - the fill is a faint breath so text has somewhere to
    sit, and the blur keeps whatever passes behind it from resolving into shapes
    that compete with the reading.
  */
  --glass: color-mix(in srgb, var(--bg-raised) 18%, transparent);
  --glass-hover: color-mix(in srgb, var(--bg-raised) 42%, transparent);
  --glass-blur: 18px;
  --rim-hi: rgba(11, 12, 14, 0.26);
  --rim-lo: rgba(11, 12, 14, 0.07);

  --f-xs: 0.79rem;
  --f-s: 0.889rem;
  --f-base: 1rem;
  --f-m: 1.125rem;
  --f-l: 1.424rem;
  --f-xl: 1.802rem;
  --f-2xl: 2.281rem;

  --s-1: 0.25rem;
  --s-2: 0.5rem;
  --s-3: 0.75rem;
  --s-4: 1rem;
  --s-5: 1.25rem;
  --s-6: 1.5rem;
  --s-8: 2rem;
  --s-10: 2.5rem;
  --s-12: 3rem;
  --s-16: 4rem;
  --s-20: 5rem;
  --s-28: 7rem;

  --measure: 66ch;
  --gutter: clamp(1.25rem, 5vw, 3rem);
  --page: 74rem;

  --font: "Inter", "Segoe UI Variable Display", "Segoe UI", -apple-system,
    BlinkMacSystemFont, Roboto, system-ui, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono",
    Menlo, monospace;

  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

  /*
    One motion system, ported from the brehon landing site's lib/motion.ts and
    globals.css. Every reveal on the page references these four, so the whole
    page reads as one choreography instead of a set of effects that happen to
    be near each other. Tune here once.
  */
  --ease-brand: cubic-bezier(0.22, 1, 0.36, 1); /* smooth settle */
  --dur-reveal: 0.62s;
  --reveal-shift: 16px; /* the only vertical motion on reveal */
  --stagger: 90ms; /* base delay for connected, staggered groups */

  /*
    Measured, not guessed: the height of .site-head, which is sticky. Every
    in-page link scrolls by this much less so the target lands below the bar
    instead of behind it. site.js re-measures it on resize; these are the
    fallback for a reader with no scripts.
  */
  --head-h: 74px;
}

/* ---------- base ---------- */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: 118.75%; /* 19px */
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /*
    The header is sticky and 74px tall, and nothing was telling the browser to
    stop short of it: every in-page link parked its heading behind the bar and
    the reader landed on the paragraph after it, which reads as the jump having
    overshot. --s-6 on top so the heading arrives below the rule rather than
    flush against it.
  */
  scroll-padding-top: calc(var(--head-h) + var(--s-6));
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font);
  font-size: var(--f-base);
  line-height: 1.62;
  letter-spacing: -0.009em;
  font-synthesis-weight: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  overflow-wrap: break-word;
}

.wrap {
  max-width: var(--page);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

main {
  padding-block: var(--s-16) var(--s-28);
}

/*
  The home page runs a narrower column than the rest of the site.

  It is the only page with the figure, and the figure is now a mirrored pair
  standing in the two margins. A column pinned to the left leaves one margin
  wide and the other nonexistent, so the left-hand ribbon had nowhere to be
  except behind the headline. Narrowing and centring the column gives the pair
  two equal margins to stand in, which is the whole point of mirroring them.

  Content pages keep the full wrapper: they carry three-column tables, and they
  have no figure to make room for.
*/
/* =========================================================
   THE BACKGROUND

   Two layers, both behind everything, neither interactive:
     1. two identical slow sweeps of light
     2. grain, to stop a large flat grey reading as dead paint

   The two sweeps are deliberately identical and half a cycle apart. The site
   argues that the interesting question is whether a system does the same thing
   twice; the background does the same thing twice.

   There was a third: a 5.5rem hairline grid, drawn by .bg::before and masked to
   fade out below the fold. It went on 2026-08-27, for two reasons.

   It was doing the grain's job, and the grain does it better - both were there
   to stop a large flat grey reading as dead paint, and only one of them reads
   as a surface rather than as a UI element someone forgot to remove. And it was
   invisible on the page it was designed for: the home page has the video over
   it now, so the grid only ever showed on the other five, where it was the
   loudest thing on an otherwise quiet ground.

   To bring it back, restore .bg::before with the two linear-gradients at
   background-size: 5.5rem 5.5rem and the radial mask.
   ========================================================= */

.bg {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  background-color: var(--bg);
}

.bg::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("/assets/grain.png");
  background-repeat: repeat;
  opacity: var(--grain);
  mix-blend-mode: overlay;
  z-index: 2;
}

/* ---------- the background video ----------

   Fixed to the viewport inside .bg, above the hairline grid and the light
   passes and below the grain, so it is behind wherever the reader has
   scrolled to. It is the ground, not an illustration in one section.

   WHY IT MULTIPLIES

   The clip is a dark ribbon on a near-white field, and --bg is #eceef0,
   which is darker than that field. Composited normally the empty part of
   every frame is LIGHTER than the page, so the video announces its own
   bounding box even in the frames with nothing in them - a pale panel with a
   seam down it.

   Under multiply, white is the identity. The field disappears into whatever
   is behind it, the grid and the light passes keep running through it, and
   only the ribbon - the part actually darker than the ground - has any
   effect. There is no edge to hide, because outside the ribbon nothing is
   being drawn. It is why this reads as one page rather than a video with a
   page on top of it.

   The consequence is that everything inside this layer is written in terms
   of white, not --bg: to take the video down you paint it white, because
   white is what leaves the page alone. That is the opposite of the usual
   instinct.
*/
/*
  The saturation is not a filter for taste, it is the wash's receipt.

  Mixing anything with white takes its saturation with it, so the wash that
  makes the ribbon safe to put text on also drains it to grey - and grey is
  not what the clip is. It is blue, on this site's own hue: the figure it
  replaced multiplied its grey by 0.34 0.68 1.60, which normalises to about
  222deg, and --accent sits there too.

  Boosting here rather than on .bg-video is deliberate: applied to the group,
  it lifts what survives the wash instead of what goes into it. White has no
  saturation to boost, so the empty field is untouched and only the ribbon
  answers - which is the same division of labour multiply is already making.
*/
.bg-video-layer {
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  pointer-events: none;
  filter: saturate(2.8);
  mix-blend-mode: multiply;
}

/*
  Two <video> elements, one on top of the other in DOM order: bg-video.js
  crossfades between them at the loop point, so only .is-on's opacity ever
  moves and the other is always the one about to take over.
*/
.bg-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 1s linear;
}

.bg-video.is-on {
  opacity: 1;
}

/*
  THE WASH IS A CONTRAST MEASUREMENT, NOT A TASTE SETTING.

  Text now sits over this, everywhere, which is what makes the number below
  load-bearing. The site holds text to 7:1 rather than the 4.5:1 the standard
  asks for, and the binding case is --fg-muted (#464c54) - it measures 7.45
  against a bare --bg, so it has almost no headroom to spend.

  Measured over twelve frames of the shipped clip, every pixel, through this
  layer's real pipeline: white wash, then the saturate() above, then multiply
  onto --bg. The number is the wash:

    80%   worst 4.67:1   45% of the clip's pixels under 7:1
    86%   worst 5.42:1   41%          <- shipped
    90%   worst 5.96:1   32%
    94%   worst 6.53:1   23%
    97%   worst 6.98:1    0.4%

  Nothing on that table reaches 7:1 and no setting can, which is the finding
  that decides this. #464c54 measures 7.45 against a bare --bg, so darkening
  the ground anywhere by more than about four percent breaks it. That is a
  fact about the palette, not about this video, and the only settings that
  respect it leave nothing to look at.

  CORRECTED 2026-08-27. The 5.42:1 above was wrong, and wrong in the
  expensive direction. It came from modelling this layer alone - wash,
  saturate, multiply onto --bg - and under the text there are five more:
  .p1, .p2, .p3, .beam and the grain. Sampling the actual rendered pixels
  under the glyph ink instead, across 61 frames at four widths, the worst
  case is 4.48:1 (.lede at 1280px, t=26s, ground rgb(180,187,197)).

  That changes the conclusion rather than the decimal. 5.42 cleared the
  standard's 4.5 with headroom; 4.48 does not clear it at all. The number
  this file called measured was arithmetic over one layer, not a pixel off
  the screen - on a site whose argument is that every claim can be checked,
  that is the part worth writing down.

  So --fg-muted is darkened to #363b42, the second of the two options this
  comment already listed - but the claim made for it here was also wrong.
  Measured over that worst frame, #363b42 gives 5.83:1, not 7:1. It buys
  back the standard with headroom; it does not buy back this site's own bar.
  Reaching 7:1 needs about #292e35, which sits close enough to body text
  that the two stop reading as separate weights. The other option - a 97%
  wash - ends the video as anything visible, which is removal rather than
  compromise.

  The honest statement of where this landed: body text 12.24:1, muted text
  between 5.83 and 9.70 depending on what is behind it, and the 7:1 bar met
  everywhere except over the brightest frames of the video.

  Body text was never in question: 12.24:1 over the same frames, and the
  muted colour only sets ledes and asides.

  The saturate() is free in this arithmetic: the filter matrix preserves
  luminance, so 1.0 and 2.8 measure identically to two decimals. The blue
  costs no contrast at all.
*/
.bg-video-wash {
  position: absolute;
  inset: 0;
  background: rgba(255, 255, 255, 0.86);
}

@media (prefers-reduced-motion: reduce) {
  .bg-video {
    transition: none;
  }
}

/*
  Below 72rem the video is not shown at all, and bg-video.js reads the same
  breakpoint and fetches nothing. A phone is charged neither the 1.28 MB nor
  the decode, and at that size the clip - cropped to a narrow portrait slice
  by object-fit: cover - was a smear rather than a ribbon anyway.
*/
@media (max-width: 71.99rem) {
  .bg-video-layer {
    display: none;
  }
}


/* ---------- the glass figure ----------

   Twenty twisted slabs, drawn in WebGL by /assets/figure.js. It is the one
   thing on the page that is purely decorative, so it behaves like decoration:
   it fades in only once it is drawing, it is blurred so the eye does not try
   to focus on it, and it is masked away from the right-hand side where the
   text lives.

   Blurring here rather than in the shader is deliberate. A blur filter is one
   cheap GPU pass, while a separable blur inside WebGL would mean two extra
   render targets for an effect nobody should notice.
*/

.figure {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  display: block;
  opacity: 0;
  /*
    A quarter of a pixel, down from 4px.

    The blur used to be doing three jobs: hiding a very low-resolution render,
    standing in for antialiasing, and keeping the figure from competing with the
    text. The first is gone - the canvas renders at native size up to its pixel
    budget - and the third was never the blur's to do, since the mask and the
    layout keep it out of the reading column.

    What is left covers the mild downscale on very wide viewports, where the
    budget puts the canvas below 1:1. Any more than this and it starts giving
    back the sharpness the higher resolution was bought for.
  */
  filter: blur(0.25px);
  transition: opacity 1.2s var(--ease-out);
}

.figure.is-on {
  opacity: 0.85;
}

/*
  Keep it out of the reading column - by not putting it there at all.

  Two earlier attempts used a percentage mask over a full-width canvas. Both
  leaked: a mask expressed in percent cannot track a column of fixed width, so
  as the viewport narrows the copy grows as a share of the screen and slides
  under the figure. It measured clean at 1920 and failed at 1280.

  So the canvas is not full width. It starts where the reading column ends and
  runs to the right edge:

    wrapper is --page (74rem) wide and centred, so it starts at 50% - 37rem
    plus the gutter, plus the widest transparent block (42rem)
    = 50% + 5rem + gutter, taken as 50% + 8rem

  and below the wrapper's full width that expression is too small, so the max()
  floors it at 45rem. The mask is now only a soft edge for blending, not the
  thing keeping text legible - if mask-image were unsupported, nothing would
  land behind a sentence anyway.
*/
/*
  Full width, with the reading column cut away.

  The canvas spans the viewport and the mask decides where it exists: nothing
  over the copy, everything in the free column to its right. The boundary is the
  same expression the figure used when it WAS a narrow column - the wrapper's
  left edge plus the widest transparent block - so the reading column is
  protected by the same arithmetic that used to position the element itself.

  It is written in rem rather than percentages on purpose. A percentage boundary
  cannot track a column measured in rem: as the viewport narrows the column
  grows as a share of the screen and slides out from under it. That failure has
  already happened in this file once, and it measured clean at 1440 while
  putting the figure at effective alpha 156 behind the headline at 1024.
*/
@media (min-width: 60rem) {
  .figure {
    inset: 0;
    width: 100%;
    mask-image: linear-gradient(
      to right,
      transparent 0,
      transparent max(calc(50% + 5rem), 42rem),
      #000 max(calc(50% + 8rem), 45rem),
      #000 100%
    );
    -webkit-mask-image: linear-gradient(
      to right,
      transparent 0,
      transparent max(calc(50% + 5rem), 42rem),
      #000 max(calc(50% + 8rem), 45rem),
      #000 100%
    );
  }
}

/*
  Below 60rem there is no free column: the copy uses the whole width, so any
  figure would be behind a sentence rather than beside it.
*/
@media (max-width: 59.99rem) {
  .figure {
    display: none;
  }
}

/*
  The figure takes the colour of the section it is on.

  --figure-hue is three multipliers applied to the finished grey, read by
  figure.js and handed to the shader. It lives here rather than in the shader so
  that a colour is changed where every other colour on this site is changed.

  They are multipliers rather than a hue rotation on purpose: the ribbon's
  brightness pattern is the part that took longest to get right, and a multiply
  leaves it exactly alone. Each set is balanced to roughly the same luminance,
  so the blue page and the red page cover their text by the same amount as the
  grey one - which is what the readability measurement below depends on.
*/
/*
  --figure-inset says whether the figure keeps out of the reading column.

  1 on the home page, where it is an object standing beside the copy and both
  the mask and the shader carve out the column. 0 on the service pages, where
  the copy is full width and the figure is a wash across the whole page instead.

  figure.js reads it, so the shader's early-out and the CSS mask always agree -
  they are two expressions of one decision, and the decision is here.
*/
.figure--grey {
  --figure-hue: 1 1 1;
  --figure-inset: 1;
}

/*
  Pushed hard on purpose.

  A gentle multiplier reads as grey with a suspicion of blue, which is worse
  than either choice - it looks like a rendering fault rather than a decision.
  These take the highlights, which are near white, well into the hue: white
  times 0.34, 0.68, 1.60 clamps to a real blue rather than a tinted grey.
*/
.figure--blue {
  --figure-hue: 0.34 0.68 1.60;
  --figure-inset: 0;
}

.figure--red {
  --figure-hue: 2.00 0.40 0.50;
  --figure-inset: 0;
}

/* No column to avoid: the wash covers the page, fading out at the very edges so
   it does not end on a hard line against the viewport. */
@media (min-width: 60rem) {
  .figure--blue,
  .figure--red {
    mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
    -webkit-mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
  }
}

/*
  On the content pages the figure is quieter than on the home page.

  Home has a genuine free column beside the copy. These pages do not: they are
  three-column tables that use the whole wrapper, so the figure is behind
  reading rather than beside it, and it was measured at effective alpha 184
  against a limit of 120 when it was simply switched on. At this opacity the
  same figure lands at well under half that - present as a colour on the page,
  not as an object competing with a table.
*/
:root .figure--blue.is-on,
:root .figure--red.is-on {
  opacity: 0.42;
}

/* Once the figure is drawing, the CSS light passes step back. Two systems both
   claiming the background is what made the earlier version restless. */
:root.has-figure .bg .p1,
:root.has-figure .bg .p2,
:root.has-figure .bg .p3 {
  opacity: 0;
  animation: none;
}

:root.has-figure .bg .beam {
  animation-duration: 74s;
}

/*
  THESE ARE THE MOST EXPENSIVE THING ON THE SITE. Measured, not suspected.

  Median frame interval during a full scroll of the home page, production,
  1440x900, headless software rendering:

    as shipped                                  252ms
    light-pass animations paused only            44ms
    light passes hidden                          40ms
    light passes and video both hidden           17ms
    video elements removed, passes kept         237ms

  So the video is not the cost - removing it entirely buys 15ms of 252 - and
  the cost is not that these elements exist. It is that they MOVE. Pausing the
  animation gets almost the whole win on its own.

  Why: p1 and p2 are 72vw x 92vh with blur(64px), p3 is 115vw x 145vh with
  blur(110px). A blur that large over a surface that large has to be
  recomputed every time the element moves, and these move continuously,
  forever, whether or not anyone is scrolling. will-change does not help,
  because the layer still has to be re-rasterised.

  What the cost buys back, if it ever needs paying down:

    blur radii halved (32/32/55)                166ms
    blur radii quartered (16/16/28)             138ms
    p3 alone removed                            127ms
    no blur at all                               89ms

  p3 is half the total by itself: the largest surface and the widest radius,
  and the faintest of the three on screen.

  Rasterising small and letting the compositor scale up was tried - a third of
  the size, a third of the radius, scale(3) folded into the keyframes - and it
  changed nothing (254ms). Chromium rasterises with the transform accounted
  for, so there is no free version of this. Every remaining option costs
  something visible, which is why none of them has been taken here.

  Read the numbers as ratios, not absolutes: this is software rendering with no
  GPU, which is the worst case and not what a visitor has.

  DECIDED 2026-08-29: the owner reviewed all of the above and is keeping the
  background exactly as it is. So this block is a record, not a backlog. Do not
  quietly "optimise" any of it - halving a radius or dropping p3 is a visual
  change to a deliberately tuned surface, and it has already been considered and
  declined with the numbers in hand.
*/
.bg i {
  position: absolute;
  border-radius: 50%;
  opacity: 0;
  will-change: transform, opacity;
}

/* Three passes of different size, hue and period. One blob reads as a blob;
   three at different speeds read as depth. */
.bg .p1 {
  top: -32vh;
  left: -45vw;
  width: 72vw;
  height: 92vh;
  background: radial-gradient(closest-side, var(--sweep), transparent 70%);
  filter: blur(64px);
}

.bg .p2 {
  top: -32vh;
  left: -45vw;
  width: 72vw;
  height: 92vh;
  background: radial-gradient(closest-side, var(--sweep), transparent 70%);
  filter: blur(64px);
}

.bg .p3 {
  top: -60vh;
  left: -60vw;
  width: 115vw;
  height: 145vh;
  background: radial-gradient(closest-side, var(--sweep-far), transparent 72%);
  filter: blur(110px);
}

/* A single beam crossing the whole page on a long period. It is what stops the
   ground reading as three blobs that happen to be moving. */
.bg .beam {
  position: absolute;
  top: -60vh;
  left: -40vw;
  width: 34vw;
  height: 240vh;
  transform: rotate(18deg);
  background: linear-gradient(
    90deg,
    transparent,
    var(--beam) 45%,
    var(--beam) 55%,
    transparent
  );
  filter: blur(46px);
  opacity: 0;
  will-change: transform, opacity;
}

@media (prefers-reduced-motion: no-preference) {
  .bg .p1 {
    animation: sweep 34s var(--ease-in-out) infinite;
  }

  /* Deliberately identical to the first, half a cycle behind. The site argues
     that the interesting question is whether a system does the same thing
     twice; the ground does the same thing twice. */
  .bg .p2 {
    animation: sweep 34s var(--ease-in-out) infinite;
    animation-delay: -17s;
  }

  .bg .p3 {
    animation: sweep-far 78s linear infinite;
    animation-delay: -30s;
  }

  .bg .beam {
    animation: beam 46s var(--ease-in-out) infinite;
    animation-delay: -8s;
  }

}

@keyframes sweep {
  0% {
    transform: translate3d(0, 0, 0) scale(1);
    opacity: 0;
  }
  12% {
    opacity: 1;
  }
  88% {
    opacity: 1;
  }
  100% {
    transform: translate3d(155vw, 26vh, 0) scale(1.3);
    opacity: 0;
  }
}

@keyframes sweep-far {
  0% {
    transform: translate3d(0, 8vh, 0) scale(1);
    opacity: 0;
  }
  18% {
    opacity: 1;
  }
  82% {
    opacity: 1;
  }
  100% {
    transform: translate3d(120vw, -14vh, 0) scale(1.35);
    opacity: 0;
  }
}

@keyframes beam {
  0% {
    transform: translate3d(-20vw, 0, 0) rotate(18deg);
    opacity: 0;
  }
  20% {
    opacity: 1;
  }
  80% {
    opacity: 1;
  }
  100% {
    transform: translate3d(150vw, 0, 0) rotate(18deg);
    opacity: 0;
  }
}

/* ---------- skip link ---------- */

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 20;
  background: var(--fg);
  color: var(--bg);
  padding: var(--s-3) var(--s-4);
  font-weight: 500;
}

.skip:focus {
  left: var(--s-2);
  top: var(--s-2);
}

/* Available to the reader who cannot see a glyph: wherever a control or a mark
   carries meaning that no visible text repeats, this is the word behind it. */
.vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* =========================================================
   THE HERO

   No dark panel here any more, and no white text. Both existed because the
   first cut of the video assumed a dark clip. It is a blue-grey ribbon on a
   near-white field, so a 50% black panel over it produced grey sludge inside
   a hard-edged rectangle, and the hero now reads in the site's own colours
   like every other section. The video itself is sitewide and fixed - see THE
   BACKGROUND, above.
   ========================================================= */

.hero {
  position: relative;
  min-height: min(70vh, 40rem);
  display: flex;
  align-items: center;
  margin-inline: calc(var(--gutter) * -1);
  padding-inline: var(--gutter);
  padding-block: var(--s-16) var(--s-12);
}

@media (max-width: 40rem) {
  .hero {
    min-height: min(85vh, 34rem);
  }
}


.hero__body {
  position: relative;
  max-width: 100%;
}

/* ---------- typography ---------- */

h1,
h2,
h3 {
  font-weight: 500;
  text-wrap: balance;
  margin: 0;
}

h1 {
  font-size: clamp(2.6rem, 7.2vw, 5.2rem);
  line-height: 0.97;
  letter-spacing: -0.043em;
  /* A ch measure scales with the font, so at 5.2rem 15ch is close to 1500px:
     too wide to read comfortably, and wide enough to reach the figure. */
  max-width: min(15ch, 40rem);
  margin-block-end: var(--s-8);
}

/*
  20ch here until 2026-08-28, which was a measure chosen for the shortest
  headings on the site and then applied to all of them. Measured at 1280px, the
  cap is 559px and five section headings wanted between 587px and 721px on one
  line: "What stops a test that proves nothing", "Where the scenarios come
  from", "How far down each check goes", "What stops it happening again", "What
  gets fixed underneath". Every one of them broke into two lines with a couple
  of words stranded on the second, which reads as a heading that ran out of room
  rather than as a deliberate break.

  28ch clears all five. It deliberately does not clear "What a suite looks like
  when it has stopped working" (998px, fifty characters), which is long enough
  that two balanced lines are the right answer.

  ch rather than rem because the font-size is a clamp: the cap then scales with
  the type, and the ratio between the cap and the text it is capping holds at
  every viewport.
*/
h2 {
  font-size: clamp(1.75rem, 3.4vw, var(--f-2xl));
  line-height: 1.1;
  letter-spacing: -0.032em;
  margin-block: var(--s-20) var(--s-6);
  max-width: 28ch;
}

h3 {
  font-size: var(--f-l);
  line-height: 1.2;
  letter-spacing: -0.026em;
  margin-block: var(--s-10) var(--s-3);
}

p,
ul,
ol {
  margin: 0 0 var(--s-5);
}

li {
  margin-block-end: var(--s-2);
}

.lede {
  font-size: clamp(1.125rem, 2.2vw, 1.4rem);
  line-height: 1.48;
  letter-spacing: -0.019em;
  color: var(--fg-muted);
  max-width: 44ch;
  margin-block-end: var(--s-12);
}

/*
  The headline arrives word by word.

  The words are wrapped at build time, not by script, so the markup is complete
  for a reader without JavaScript, and the per-word delays live here as
  :nth-child rules rather than inline style attributes - which would have
  required unsafe-inline in the CSP.

  This is an entrance, not a scroll reveal, so it may fade: it runs once, on
  load, and always finishes. The rule that motion must never leave text
  unreadable still holds, which is why `both` fill-mode pins the end state and
  reduced motion removes it entirely.
*/
.split .w {
  display: inline-block;
}

@media (prefers-reduced-motion: no-preference) {
  .split .w {
    animation: word-in 0.66s var(--ease-out) both;
  }

  .split .w:nth-child(1) { animation-delay: 40ms; }
  .split .w:nth-child(2) { animation-delay: 100ms; }
  .split .w:nth-child(3) { animation-delay: 160ms; }
  .split .w:nth-child(4) { animation-delay: 220ms; }
  .split .w:nth-child(5) { animation-delay: 280ms; }
  .split .w:nth-child(6) { animation-delay: 340ms; }
  .split .w:nth-child(7) { animation-delay: 400ms; }
  .split .w:nth-child(8) { animation-delay: 460ms; }
  .split .w:nth-child(9) { animation-delay: 520ms; }
  .split .w:nth-child(10) { animation-delay: 580ms; }
  .split .w:nth-child(n + 11) { animation-delay: 640ms; }

  .lede,
  main > p:first-of-type {
    animation: lede-in 0.7s var(--ease-out) 0.42s both;
  }
}

@keyframes word-in {
  from {
    opacity: 0;
    filter: blur(9px);
    transform: translateY(18px);
  }
  to {
    opacity: 1;
    filter: blur(0);
    transform: none;
  }
}

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

.eyebrow {
  display: block;
  font-size: var(--f-s);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--fg-muted);
  margin-block-end: var(--s-6);
  padding-block-end: var(--s-4);
  border-bottom: 1px solid var(--line);
  max-width: none;
}

strong {
  font-weight: 600;
}

/*
  The § 3a identification block: business address, IČO and trade register
  entry, which Slovak law requires a sole trader to publish. It is a record
  rather than prose, so it sets its own line-height instead of inheriting the
  1.62 that reading text wants - at that spacing five short lines read as five
  separate paragraphs.
*/
.ident {
  line-height: 1.5;
  color: var(--fg-muted);
  padding-inline-start: var(--s-4);
  border-inline-start: 2px solid var(--line);
}

code {
  font-family: var(--mono);
  font-size: 0.87em;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.08em 0.32em;
  overflow-wrap: anywhere;
}

pre {
  font-family: var(--mono);
  font-size: var(--f-s);
  background: var(--bg-raised);
  border: 1px solid var(--line);
  padding: var(--s-5);
  overflow-x: auto;
  line-height: 1.55;
}

pre code {
  background: none;
  border: 0;
  padding: 0;
  white-space: pre;
  font-size: inherit;
}

/* hr is styled with the other section breaks, further down: it fades at both
   ends rather than running edge to edge across the figure. */

blockquote {
  margin: var(--s-8) 0;
  padding-left: var(--s-6);
  border-left: 2px solid var(--fg);
  font-size: var(--f-m);
  line-height: 1.5;
  color: var(--fg);
}

/* ---------- links ---------- */

/*
  A link in running text is the one thing on a page of prose that does
  something, so it is the one thing in running text that is coloured. Links that
  are already something else - the buttons, the whole-card links, the nav, the
  footer columns - restate their own colour below and are unaffected.
*/
a {
  color: var(--accent);
  text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
  text-underline-offset: 0.22em;
  text-decoration-thickness: 1px;
  transition: color 180ms var(--ease-out),
    text-decoration-color 180ms var(--ease-out);
}

a:hover {
  color: var(--accent-strong);
  text-decoration-color: currentColor;
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 2px;
}

/* ---------- header ---------- */

.site-head {
  position: sticky;
  top: 0;
  z-index: 10;
  border-bottom: 1px solid var(--line);
  background: color-mix(in srgb, var(--bg) 72%, transparent);
  backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  padding-block: var(--s-4);
}

/* The header arrives too. It was the one region of the page with no entrance
   at all, which read as the content loading underneath a chrome that had
   always been there. It comes from -14px rather than further, because it is
   sticky and must not appear to detach from the top edge. */
@media (prefers-reduced-motion: no-preference) {
  .site-head {
    animation: head-in 0.55s var(--ease-out) both;
  }
}

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

.site-head__inner {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3) var(--s-8);
  align-items: center;
  justify-content: space-between;
}

/* The wordmark carries the header, so it is sized like a wordmark rather than
   like a nav item. It used to sit at --f-s, the same as the links beside it,
   which left the header with no anchor at all. */
.site-head__name {
  font-weight: 600;
  font-size: var(--f-m);
  letter-spacing: -0.022em;
  text-decoration: none;
  color: var(--fg);
  transition: opacity 180ms var(--ease-out);
}

.site-head__name:hover {
  opacity: 0.62;
}

.nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-6);
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--f-base);
  align-items: center;
}

.nav li {
  margin: 0;
}

.nav a {
  position: relative;
  color: var(--fg-muted);
  text-decoration: none;
  padding-block: 2px;
  transition: color 180ms var(--ease-out);
}

/* The rule grows from the centre on hover rather than fading in. Transform is
   composited, and the growth reads as a response to the pointer rather than a
   state that was always there. */
.nav a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transition: transform 200ms var(--ease-out);
}

.nav a:hover {
  color: var(--accent);
}

.nav a:hover::after {
  transform: scaleX(1);
}

.nav a[aria-current="page"] {
  color: var(--accent);
}

.nav a[aria-current="page"]::after {
  transform: scaleX(1);
}

/* =========================================================
   BLOCKS: one entrance, and a lift on hover

   Every block of the page arrives rather than simply being there, and it does
   so exactly once, on one clock: an IntersectionObserver adds a class the first
   time the block comes near the viewport, and a transition carries it up 16px.
   The rules are at the end of this file, under "Arrival".

   What used to be here was two more systems on top of that one - a staggered
   load animation on every main > * and a scroll-linked rise on everything from
   the third child down. Both wrote transform and one wrote opacity, on the same
   elements the observer was already animating. Three clocks on one property is
   not a stagger, it is a fight, and it is what made the entrances read as
   arbitrary.

   Every block also answers the pointer. The lift is small - a tint, a rule, two
   pixels of movement - because these are paragraphs and table rows, not
   buttons, and a hover state that shouts is worse than none.
   ========================================================= */

/* =========================================================
   GLASS

   The panels of this page sit over a moving figure. They used to sit on top of
   it - an opaque --bg rectangle punched a hole straight through the figure and
   left a hard edge across it, which is the thing that made the background look
   like it was behind a mistake rather than behind a page.

   They are surfaces now. Each one lets the ground through, blurs it so it
   cannot compete with the text, and carries a rim: a gradient border, bright at
   the top and bottom and absent across the middle of each side, which is where
   a real curved edge would and would not catch the light. Two-thirds of the
   glassmorphism effect is that rim, not the blur.

   Contrast is unaffected. The blur means whatever lands behind a panel arrives
   as a soft wash, and the fill is opaque enough that the text still resolves
   against --bg-raised - which is what the contrast checks measure, since they
   composite declared backgrounds and know nothing about a canvas.
   ========================================================= */

/* Named by what they are rather than by a class, so the markdown pages get the
   same surface without needing a class markdown cannot add. */
.cards > li,
.rail__card,
.callout,
.btn--quiet,
.glass {
  position: relative;
  background: var(--glass);
  backdrop-filter: blur(var(--glass-blur)) saturate(130%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(130%);
}

/*
  The rim, drawn with the mask-composite trick: paint the gradient over the
  whole box, then subtract the padding box from it, leaving only the 1px frame.
  A plain border cannot do this because a border takes one colour per side.
*/
.cards > li::before,
.rail__card::before,
.callout::before,
.btn--quiet::before,
.glass::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(
    180deg,
    var(--rim-hi) 0%,
    var(--rim-lo) 22%,
    transparent 42%,
    transparent 58%,
    var(--rim-lo) 78%,
    var(--rim-hi) 100%
  );
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  mask-composite: exclude;
  pointer-events: none;
  opacity: 0.65;
  transition: opacity 240ms var(--ease-out);
}

/* The rim brightening is most of what the pointer response is. The fill moves
   too, but a surface catching more light is what reads as "this one". */
.cards > li:hover::before,
.cards > li:focus-within::before,
.rail__card:hover::before,
.callout:hover::before,
.btn--quiet:hover::before,
.glass:hover::before,
.glass:focus-within::before {
  opacity: 1;
}

/* ---------- hover ---------- */

.cards > li,
.numbers > li,
.steps li,
tbody td,
details,
.callout,
blockquote,
.next {
  transition: background 220ms var(--ease-out), border-color 220ms var(--ease-out),
    box-shadow 220ms var(--ease-out), transform 220ms var(--ease-out);
}

.numbers > li {
  padding: var(--s-4) var(--s-5);
  margin-inline: calc(var(--s-5) * -1);
  border-left: 2px solid transparent;
}

.numbers > li:hover {
  background: var(--tint);
  border-left-color: var(--accent);
}

.steps li:hover {
  background: var(--tint);
}

.steps li {
  padding-inline: var(--s-4);
  margin-inline: calc(var(--s-4) * -1);
  border-left: 2px solid transparent;
}

.steps li:hover {
  border-left-color: var(--accent);
}

/* The card lifts and its glass thickens: more of the panel, less of the figure
   through it. Going the other way - clearer on hover - reads as the card
   getting out of the way of the thing behind it, which is backwards. */
.cards > li:hover {
  background: var(--glass-hover);
  box-shadow: inset 3px 0 0 var(--accent);
  transform: translateY(-2px);
}

/* Applied to the cells, not the row. With border-collapse, a background on
   <tr> is unreliable - WebKit left it fully transparent while Chromium and
   Firefox painted it, so the hover state simply did not exist in Safari. */
/* Two selectors for one state. tr:hover is the obvious one and it is what
   Chromium and Firefox act on; WebKit left the cells transparent under it, so
   :has() gives the same rule a second route. Either match highlights the whole
   row, which is the point - highlighting only the cell under the pointer would
   be worse than no hover at all. */
tbody tr:hover td,
tbody tr:has(td:hover) td {
  background: var(--tint);
}

tbody tr:hover td:first-child,
tbody tr:has(td:hover) td:first-child {
  box-shadow: inset 2px 0 0 var(--accent);
}

details:hover {
  border-top-color: var(--accent);
  background: var(--tint);
}

/*
  The blocks that had no pointer response at all.

  These are the ones the owner was pointing at: a page where some things answer
  the pointer and others do not reads as half-finished, because the reader
  cannot tell which of the two states means anything. So everything that is a
  block of content now responds - quietly, at the same 220ms, and only ever with
  a tint that lets the ground stay visible through it.
*/
.callout:hover {
  background: var(--glass-hover);
}

blockquote {
  transition: background 220ms var(--ease-out), border-color 220ms var(--ease-out);
}

blockquote:hover {
  background: var(--tint);
  border-left-color: var(--accent);
}

.next:hover {
  border-top-color: var(--fg-muted);
}

/* ---------- reveal on scroll ----------

   Deleted, not moved. Every rule that used to live here drove a second
   animation on an element whose parent was already animating: the cards, the
   list items, the table rows and the footer columns each ran row-in off their
   own view() timeline while the block containing them ran block-in off the
   clock and the reveal off an observer.

   There is now one reveal for the whole page, at the end of this file, and
   groups that should arrive in sequence get --stagger rather than a scroll
   range nudged a few percent along. */

/* ---------- service cards on the home page ---------- */

/*
  The dividing hairlines used to be the container's own background showing
  through a 1px grid gap. That trick requires the cards to be opaque, and the
  cards are glass now - a solid --line behind a translucent card paints the
  whole card that colour and hides everything the translucency was for.

  So the lines move onto the cards as outlines. An outline is drawn outside the
  border box and takes no space, so two neighbours' outlines meet in the 1px gap
  and read as one shared rule, exactly as the old gap did.
*/
.cards {
  max-width: none;
  display: grid;
  gap: 1px;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
  list-style: none;
  padding: 0;
  margin-block: var(--s-12);
}

.cards > li {
  margin: 0;
  padding: var(--s-10) var(--s-8);
  display: flex;
  flex-direction: column;
  position: relative;
  outline: 1px solid var(--line);
}

.cards h2 {
  margin-block: 0 var(--s-4);
  font-size: var(--f-xl);
  max-width: none;
}

.cards p {
  color: var(--fg-muted);
  margin-block-end: var(--s-5);
}

.card-link {
  margin-block-start: auto;
  align-self: flex-start;
  font-weight: 500;
  text-decoration: none;
  border-bottom: 1px solid var(--fg);
  padding-block-end: 2px;
  display: inline-flex;
  align-items: baseline;
  gap: var(--s-2);
  transition: opacity 180ms var(--ease-out), transform 160ms var(--ease-out);
}

/* The arrow moves, not the whole label: the text stays where the eye left it,
   and only the affordance responds. */
.card-link span {
  display: inline-block;
  transition: transform 220ms var(--ease-out);
}

.cards > li:hover .card-link span,
.card-link:hover span,
.card-link:focus-visible span {
  transform: translateX(5px);
}

.card-link:active {
  opacity: 0.6;
  transform: scale(0.99);
}

/* =========================================================
   THE RAIL

   Five benefit cards. The markup is a list and stays one unless site.js
   upgrades it, so this has two jobs: look right as a list, and look right as a
   carousel, without either state needing markup the other does not have.

   The static state is a grid. The carousel state - .is-carousel, added by
   script - turns the same track into a flex row where every card is exactly one
   viewport of the rail wide, and slides it with a transform.
   ========================================================= */

.rail {
  max-width: none;
  margin-block: var(--s-12);
  position: relative;
}

.rail__track {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--s-6);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}

.rail__card {
  margin: 0;
  padding: var(--s-8) var(--s-6);
  position: relative;
  outline: 1px solid var(--line);
}

.rail__card b {
  display: block;
  font-size: clamp(1.6rem, 3.4vw, 2.3rem);
  line-height: 1.05;
  letter-spacing: -0.04em;
  font-weight: 500;
  margin-block-end: var(--s-4);
  text-wrap: balance;
}

.rail__card p {
  color: var(--fg-muted);
  font-size: var(--f-base);
  line-height: 1.55;
  margin: 0;
}

/* ---- carousel state ---- */

.rail.is-carousel {
  overflow: hidden;
}

.rail.is-carousel .rail__track {
  display: flex;
  gap: 0;
  transition: transform 500ms var(--ease-in-out);
  will-change: transform;
}

.rail.is-carousel .rail__card {
  flex: 0 0 100%;
  min-height: 15rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

@media (prefers-reduced-motion: reduce) {
  .rail.is-carousel .rail__track {
    transition: none;
  }
}

.rail__nav {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  margin-block-start: var(--s-5);
}

.rail__arrow {
  appearance: none;
  cursor: pointer;
  background: transparent;
  color: var(--fg-muted);
  border: 1px solid var(--line);
  border-radius: 999px;
  width: 2.2rem;
  height: 2.2rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  flex: none;
  transition: color 160ms var(--ease-out), border-color 160ms var(--ease-out),
    transform 160ms var(--ease-out);
}

.rail__arrow svg {
  width: 1rem;
  height: 1rem;
}

.rail__arrow:hover {
  color: var(--fg);
  border-color: var(--fg-muted);
}

.rail__arrow:active {
  transform: scale(0.94);
}

.rail__dots {
  display: flex;
  gap: var(--s-2);
  align-items: center;
}

/* The hit area is the button; the visible dot is its ::before. A 8px target is
   below every guideline there is, so the button stays 24px and only looks
   small. */
.rail__dot {
  appearance: none;
  cursor: pointer;
  background: none;
  border: 0;
  padding: 0;
  width: 1.25rem;
  height: 1.25rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.rail__dot::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  border: 1px solid var(--fg-muted);
  transition: background 200ms var(--ease-out), border-color 200ms var(--ease-out);
}

.rail__dot:hover::before {
  border-color: var(--fg);
}

.rail__dot[aria-current]::before {
  background: var(--fg);
  border-color: var(--fg);
}

/* =========================================================
   THE FLOW: four steps, drawn as a flow

   An ordered list carries the sequence for a screen reader and for anyone
   without CSS. The connectors are pseudo-elements, so they add a picture for
   people who read pictures and nothing at all to the accessibility tree.
   ========================================================= */

/*
  Four explicit columns, not auto-fit.

  auto-fit with an 11rem minimum inside a 46rem box computed to 200px a column
  once the gaps were taken out, which is under the minimum, so it silently
  dropped to three and left step 04 stranded on a row of its own with the
  connector from 03 pointing at nothing. A four-step process wants four columns
  or two rows of two - never three and a remainder - so the counts are stated
  and the breakpoints are chosen rather than derived.
*/
.flow {
  max-width: none;
  list-style: none;
  counter-reset: flow;
  padding: 0;
  margin-block: var(--s-8) var(--s-6);
  display: grid;
  gap: var(--s-8) var(--s-6);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
}

@media (max-width: 62rem) {
  .flow {
    grid-template-columns: repeat(2, 1fr);
    max-width: 34rem;
  }
}

@media (max-width: 34rem) {
  .flow {
    grid-template-columns: 1fr;
    gap: var(--s-6);
  }
}

.flow li {
  counter-increment: flow;
  margin: 0;
  position: relative;
  padding-block-start: var(--s-8);
  color: var(--fg-muted);
  /* Was --f-s, which is the size of a footnote. These are four steps that carry
     the whole proposition, so they read at body size. */
  font-size: var(--f-base);
  line-height: 1.62;
}

/* The step number sits on its own rule above the text, and the rule is what the
   arrow grows out of. */
.flow li::before {
  content: counter(flow, decimal-leading-zero);
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  font-family: var(--mono);
  font-size: var(--f-s);
  color: var(--fg-muted);
  padding-block-end: var(--s-2);
}

/* The connector runs from the step number into the gap, reaching the next
   step's number. It fades out as it goes, so it reads as direction rather than
   as a rule ruled across the page. */
.flow li::after {
  content: "";
  position: absolute;
  inset-block-start: 0.55em;
  inset-inline-start: 2.6em;
  inset-inline-end: calc(var(--s-5) * -1);
  height: 1px;
  background: linear-gradient(to right, var(--line), color-mix(in srgb, var(--line) 25%, transparent));
}

/*
  A connector points at the next step, so the step at the end of a row must not
  have one - it would point off the edge at nothing. CSS has no "last in its
  grid row" selector, so the column count is restated per breakpoint. This is
  the cost of the explicit grid above, and it is worth paying: the alternative
  was a layout that silently chose its own column count and got it wrong.
*/
.flow li:last-child::after {
  display: none;
}

.flow li:nth-child(4n)::after {
  display: none;
}

@media (max-width: 62rem) {
  .flow li:nth-child(4n)::after {
    display: block;
  }

  .flow li:nth-child(2n)::after {
    display: none;
  }
}

@media (max-width: 34rem) {
  .flow li::after {
    display: none;
  }
}

.flow b {
  display: block;
  font-weight: 500;
  color: var(--fg);
  font-size: var(--f-m);
  letter-spacing: -0.018em;
  margin-block-end: var(--s-3);
}

.flow__note {
  max-width: none;
  color: var(--fg-muted);
  font-size: var(--f-m);
  line-height: 1.5;
  margin-block: var(--s-4) var(--s-12);
}

.flow__note strong {
  color: var(--fg);
}

.rail__card,
.flow li {
  transition: background 220ms var(--ease-out), border-color 220ms var(--ease-out);
}

.rail__card:hover {
  background: var(--glass-hover);
}

/* ---------- numbers ---------- */

.numbers {
  max-width: none;
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--s-10);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
  margin-block: var(--s-12);
  /* Capped so the row stays inside the reading column instead of running the
     full wrapper width and under the figure. */
  max-width: 42rem;
}

.numbers > li {
  margin: 0;
}

.numbers b {
  display: block;
  font-size: clamp(2rem, 4.4vw, 3.1rem);
  line-height: 1;
  letter-spacing: -0.045em;
  font-weight: 500;
  margin-block-end: var(--s-3);
}

.numbers span {
  display: block;
  color: var(--fg-muted);
  font-size: var(--f-s);
  line-height: 1.5;
}

/* ---------- steps ---------- */

.steps {
  max-width: 42rem;
  list-style: none;
  counter-reset: step;
  padding: 0;
  margin-block: var(--s-6) var(--s-12);
}

.steps li {
  counter-increment: step;
  margin: 0;
  padding: var(--s-5) 0 var(--s-5) var(--s-10);
  border-bottom: 1px solid var(--line);
  position: relative;
}

.steps li::before {
  content: counter(step, decimal-leading-zero);
  position: absolute;
  left: 0;
  top: var(--s-5);
  font-family: var(--mono);
  font-size: var(--f-s);
  color: var(--fg-muted);
}

.steps b {
  display: block;
  font-weight: 500;
  margin-block-end: var(--s-2);
}

.steps li {
  color: var(--fg-muted);
}

.steps b {
  color: var(--fg);
}

/* Each step arrives from its own scroll position, so they land in sequence
   rather than as one slab. That rule now lives with the other item reveals,
   next to the cards and the table rows it is identical to. */

/* ---------- disclosure ---------- */

details {
  max-width: none;
  border-top: 1px solid var(--line);
  margin-block: var(--s-4);
}

/*
  The chevron sits before the label, not at the far right of the page.

  It used to be pushed out by justify-content: space-between across a
  full-bleed <details>, which put the only thing you can click a screen's width
  away from the words telling you what it opens. Now it leads the line, which
  is also where the checklist items put their own marker.
*/
details > summary {
  cursor: pointer;
  list-style: none;
  padding-block: var(--s-5);
  font-weight: 500;
  font-size: var(--f-m);
  letter-spacing: -0.02em;
  display: flex;
  align-items: baseline;
  gap: var(--s-4);
  max-width: none;
  transition: color 160ms var(--ease-out);
}

details > summary::-webkit-details-marker {
  display: none;
}

details > summary::before {
  content: "";
  flex: none;
  align-self: center;
  width: 8px;
  height: 8px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: rotate(-45deg);
  transition: transform 220ms var(--ease-out);
  color: var(--fg-muted);
}

details[open] > summary::before {
  transform: rotate(45deg);
}

/* The chevron brightens and the label holds. This used to dim the whole
   summary to --fg-muted on hover, which is the one direction a hover state
   must never go: pointing at a control made it look less available. */
details > summary:hover::before {
  color: var(--fg);
}

/*
  Disclosure contents take the band, like every other run of prose.

  --measure (66ch) here until 2026-08-29, which put the twenty-five Q&A
  paragraphs on the home page at 791px inside a page whose body text runs 1292.
  A reader opening "Questions I get asked" watched the column shrink by a third
  for no reason they could name.
*/
details > *:not(summary) {
  max-width: none;
}

details > :last-child {
  margin-block-end: var(--s-8);
}

/*
  Real height animation on open and close.

  <details> normally snaps open, because `height: auto` is not an animatable
  value. interpolate-size lets the browser interpolate to a keyword, and
  ::details-content gives a box to animate. Where either is unsupported the
  element simply snaps as it always did - the content is identical either way,
  which is why this is safe as an enhancement rather than a dependency.

  260ms with ease-out: the design skill's range for a disclosure, and ease-out
  so the movement starts immediately at the moment the user is watching.
*/
@supports (interpolate-size: allow-keywords) {
  @media (prefers-reduced-motion: no-preference) {
    :root {
      interpolate-size: allow-keywords;
    }

    details::details-content {
      block-size: 0;
      overflow: clip;
      transition: block-size 260ms var(--ease-out), content-visibility 260ms allow-discrete;
    }

    details[open]::details-content {
      block-size: auto;
    }
  }
}

/* ---------- evidence table ---------- */

.table-wrap {
  max-width: none;
}

table {
  width: 100%;
  max-width: none;
  border-collapse: collapse;
  font-size: var(--f-s);
  text-align: left;
  margin-block: var(--s-6) var(--s-12);
}

thead th {
  font-size: var(--f-xs);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fg-muted);
  border-bottom: 1px solid var(--fg);
  padding: 0 var(--s-6) var(--s-3) 0;
  vertical-align: bottom;
}

tbody td {
  border-bottom: 1px solid var(--line);
  padding: var(--s-5) var(--s-6) var(--s-5) 0;
  vertical-align: top;
  line-height: 1.5;
}

tbody tr {
  transition: background 200ms var(--ease-out);
}

tbody tr:hover {
  background: var(--bg-raised);
}

tbody tr td:first-child {
  width: 34%;
  color: var(--fg);
}

/*
  The signature element: the third column says what each number does NOT prove.
  It is the argument of the whole site, so it is the one thing that gets a
  deliberate treatment, and nothing else competes with it.
*/
tbody tr td:nth-child(3):last-child {
  width: 33%;
  color: var(--fg-muted);
  border-left: 1px solid var(--fg);
  padding-left: var(--s-5);
}

@media (max-width: 62rem) {
  table,
  thead,
  tbody,
  tr,
  td {
    display: block;
    width: auto;
  }

  thead {
    position: absolute;
    left: -9999px;
  }

  tbody tr {
    border: 1px solid var(--line);
    padding: var(--s-6);
    margin-block-end: var(--s-6);
  }

  tbody td {
    border-bottom: 0;
    padding: 0 0 var(--s-5) 0;
  }

  tbody tr td:first-child,
  tbody tr td:nth-child(3):last-child {
    width: auto;
  }

  tbody td:last-child {
    padding-bottom: 0;
  }

  tbody td::before {
    content: attr(data-label);
    display: block;
    font-size: var(--f-xs);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--fg-muted);
    font-weight: 500;
    margin-block-end: var(--s-2);
  }

  tbody tr td:nth-child(3):last-child {
    border-left: 0;
    border-top: 1px solid var(--fg);
    padding: var(--s-5) 0 0 0;
    margin-block-start: var(--s-3);
  }
}

/* ---------- term and check ---------- */

/*
  A component, and the thing you can do yourself to find out whether it is
  really there. The verification is the argument, so it gets the body text and
  the term gets the label treatment, not the other way round.
*/
.checks {
  max-width: none;
  margin-block: var(--s-6) var(--s-12);
}

.checks dt {
  font-weight: 500;
  color: var(--fg);
  border-block-start: 1px solid var(--line);
  padding-block-start: var(--s-5);
  margin-block-start: var(--s-5);
}

.checks dt:first-of-type {
  border-block-start: 0;
  padding-block-start: 0;
  margin-block-start: 0;
}

.checks dd {
  margin: var(--s-2) 0 0;
  color: var(--fg-muted);
  line-height: 1.62;
}

/* ---------- callout ---------- */

.callout {
  max-width: none;
  border: 1px solid var(--fg);
  padding: var(--s-8);
  margin-block: var(--s-10);
}

.callout > :last-child {
  margin-block-end: 0;
}

.callout h2,
.callout h3 {
  margin-block: 0 var(--s-4);
  font-size: var(--f-l);
  max-width: none;
}

/* =========================================================
   SECTION BREAKS: light, not lines

   The full-width hairlines that used to separate the closer, the footer and the
   trailing links ran edge to edge, straight across the figure. A 1px rule
   drawn over a piece of glass reads as a scratch on it - the eye takes it as
   damage to the image rather than as structure in the page.

   So the breaks are gradients now. Each is a band that fades up out of nothing
   and back down, brightest in the middle of the reading column and gone by the
   edges, which is exactly where the figure is. Nothing crosses it.

   They are `mask`ed rather than coloured with transparency at the ends, so one
   declaration works on both themes: the band takes --line and the mask decides
   where it exists.
   ========================================================= */

.rule {
  border: 0;
  height: 1px;
  background: var(--line);
  mask-image: linear-gradient(to right, transparent, #000 22%, #000 62%, transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 22%, #000 62%, transparent);
}

/* ---------- footer and next links ---------- */

.next {
  max-width: none;
  margin-block-start: var(--s-20);
  padding-block-start: var(--s-5);
  font-size: var(--f-s);
  color: var(--fg-muted);
  position: relative;
}

.next::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  height: 1px;
  background: var(--line);
  mask-image: linear-gradient(to right, transparent, #000 18%, #000 58%, transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 18%, #000 58%, transparent);
}
  height: 1em;
  flex: none;
  transition: transform 220ms var(--ease-out);
}

.btn:hover .arrow,
.btn:focus-visible .arrow {
  transform: translate(3px, -3px);
}

/* .btn--quiet used to be declared here, ahead of .btn itself. Both are one
   class deep, so order decided the cascade and the base rule won every property
   the modifier set: the "quiet" button has been rendering as a second solid
   button for as long as it has existed. It now lives immediately after .btn,
   with the rest of the button rules. */

/* ---------- footer ---------- */

.site-foot {
  padding-block: var(--s-12) var(--s-16);
  color: var(--fg-muted);
  font-size: var(--f-s);
  position: relative;
}

.site-foot::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  height: 1px;
  background: var(--line);
  mask-image: linear-gradient(to right, transparent, #000 20%, #000 60%, transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 20%, #000 60%, transparent);
}

.foot-cols {
  display: grid;
  gap: var(--s-8) var(--s-6);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
  margin-block-end: var(--s-12);
}

.foot-col p {
  margin-block: 0 var(--s-2);
  line-height: 1.5;
}

.foot-col__h {
  color: var(--fg);
  font-weight: 500;
  margin-block-end: var(--s-3) !important;
}

.foot-col a {
  color: var(--fg-muted);
  text-decoration-color: transparent;
  transition: color 180ms var(--ease-out), text-decoration-color 180ms var(--ease-out);
}

.foot-col a:hover {
  color: var(--accent);
  text-decoration-color: currentColor;
}

.foot-base {
  padding-block-start: var(--s-5);
  margin: 0;
  font-size: var(--f-xs);
  position: relative;
}

.foot-base::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  height: 1px;
  background: var(--line);
  mask-image: linear-gradient(to right, transparent, #000 20%, #000 60%, transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 20%, #000 60%, transparent);
}

/* Free-standing rules get the same treatment: fade in, fade out, never reach
   the edge where the figure is. */
hr {
  border: 0;
  height: 1px;
  background: var(--line);
  margin-block: var(--s-16);
  mask-image: linear-gradient(to right, transparent, #000 20%, #000 60%, transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 20%, #000 60%, transparent);
}

/* ---------- contact ---------- */

.email-block {
  font-size: clamp(1.25rem, 3.4vw, 2rem);
  letter-spacing: -0.035em;
  font-weight: 500;
  word-break: break-word;
  margin-block-end: var(--s-8);
}

.email-block a {
  text-decoration-color: var(--line);
}

/*
  The primary action is the one solid block of colour on the page, and it is the
  only element that is coloured rather than merely marked in colour. Hover
  darkens along the same hue instead of fading the whole button out: opacity
  would have let the ground through and dropped the label under its own contrast
  floor at exactly the moment the pointer is on it.
*/
.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font: inherit;
  font-weight: 500;
  color: var(--bg);
  background: var(--accent);
  border: 1px solid var(--accent);
  padding: var(--s-3) var(--s-6);
  cursor: pointer;
  text-decoration: none;
  transition: background 180ms var(--ease-out), border-color 180ms var(--ease-out),
    transform 160ms var(--ease-out);
}

.btn:hover {
  background: var(--accent-strong);
  border-color: var(--accent-strong);
}

.btn:active {
  transform: scale(0.97);
}

/*
  The secondary action is glass, the primary is solid.

  Two buttons of the same weight side by side ask the reader to choose between
  them; this way one is obviously the answer and the other is obviously still
  available. It has to sit after .btn to say so - see the note where it used to
  live, further up.

  The fill and the rim come from the glass block; only the properties .btn also
  sets need restating here.
*/
.btn--quiet {
  color: var(--fg);
  background: var(--glass);
  border-color: var(--line);
}

.btn--quiet:hover {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--glass-hover);
}

/* ---------- reduced motion: nothing moves, at all ---------- */

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation: none !important;
    transition: none !important;
  }
}

/* ---------- print ---------- */

/* A second line under the duration, for the engagement whose calendar is not a
   number until the Assessment has produced one. */
.tile__sub {
  font-size: var(--f-xs);
  line-height: 1.4;
  color: var(--fg-muted);
  margin-block-start: calc(var(--s-3) * -1 + var(--s-1));
}

/* ---------- what every engagement shares ---------- */

/*
  The two sentences that introduce the checklist: what every engagement carries,
  and the warning that not every item applies to every engagement. They were
  written when a comparison table stood here and had to stop a reader taking a
  column of absences for a shortfall; the table is gone and the second sentence
  does the same job for the list.
*/
/*
  Full band width, like everything else in this section.

  46rem here first, on the reasoning that prose wants a reading measure whatever
  the block around it does. Measured, that left these two paragraphs stopping at
  931px while the list under them ran to 1223 - and with the list widened, the
  short stop read as the paragraphs having run out rather than as a set measure.

  It survives the usual objection because of what these two sentences are: one
  of 88 characters and one of about 250. At this width they set as one line and
  two. A long measure costs you on the return sweep to the start of the next
  line, and a paragraph with one return sweep in it does not have that problem.
  The rule does not generalise to running text, and is not applied to any.
*/
.checklist__all {
  max-width: none;
  margin-block: var(--s-8) var(--s-4);
}

/*
  A disclosure widget, so it is a <details>. The custom collapsible this replaced
  needed script to fold, script to unfold, and a grid-fr transition that resolved
  the open track to the padding alone - and the page already carries four of these
  further down, so the reader has met the control before.
*/
/*
  Full band width, not 46rem.

  "What is included" sits between the tiles and the process, both of which run
  the full 1166px of the band. At 46rem the disclosure stopped at 907px and the
  list inside it at 884px, so the section read as a pinch between two full-width
  blocks - measurably narrower than its neighbours rather than deliberately set
  in from them.

  The reading measure is not what is being widened here. Every run of prose
  inside still has its own cap: the two sentences above stay at 46rem, and the
  verification text inside each item stays at 60ch. What reaches the band edge
  is the row, which is a label and a marker and wants the width.
*/
.checks-more {
  max-width: none;
  margin-block: 0 var(--s-10);
}

.checks-more .checks {
  margin-block: var(--s-5) var(--s-6);
}

/* ---------- the checklist opens its own verification ---------- */

/*
  Fifteen items, each a control. Pressing one opens the answer to "how would I
  know you actually did this" directly under the item it belongs to.

  This drove the rows of a 4x9 comparison table until 2026-08-27. The table went
  because of what it did not say - Coverage and Rescue scored identically on
  every row, so at the one point where a buyer is choosing between the two real
  builds it was silent - and the fifteen verifications it carried came here
  intact. The folding is the same folding; only the selectors changed.

  With scripts off nothing collapses: every verification is simply visible. The
  script's whole job is to fold them, never to reveal them.
*/
.checklist {
  max-width: none;
  list-style: none;
  padding: 0;
  margin-block: var(--s-6) var(--s-12);
}

.check {
  margin: 0;
  border-block-start: 1px solid var(--line);
  cursor: pointer;
  transition: background 220ms var(--ease-out);
}

.check:first-child {
  border-block-start: 0;
}

.check:hover {
  background: var(--tint);
}

.check__toggle {
  appearance: none;
  font: inherit;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
  background: transparent;
  border: 0;
  padding-block: var(--s-4);
  padding-inline: 0;
  width: 100%;
  color: var(--fg);
  display: flex;
  /* center, not baseline: the icon is drawn (see below), not a glyph, so it
     has no baseline of its own to line up against the row text. */
  align-items: center;
  gap: var(--s-2);
  transition: color 160ms var(--ease-out);
}

/*
  A drawn box rather than a bare "+".

  Fifteen items each beginning with a loose plus sign read as a bulleted list
  that happened to use the wrong bullet. A bordered square the size of the type
  is unmistakably a control, and it is the only place in the list that carries a
  border, so nothing else competes to be pressed.

  The plus/minus itself is two background bars rather than the characters "+"
  and "-": text glyphs sit inside a line box shaped by the font's own ascent
  and descent, which for most monospace fonts is not symmetric around the
  glyph - centering that box does not centre the ink inside it, off by a
  couple of pixels in a way that changes with every font. A drawn bar has no
  such box; background-position: center centres it on the square exactly,
  on any font.
*/
.check__toggle::before {
  content: "";
  color: var(--fg-muted);
  flex: none;
  width: 1.15rem;
  height: 1.15rem;
  border: 1px solid var(--line);
  background-repeat: no-repeat;
  background-position: center;
  background-image: linear-gradient(currentColor, currentColor),
    linear-gradient(currentColor, currentColor);
  /* horizontal bar, vertical bar - together a "+"; the vertical one is
     collapsed to zero width below to leave only the "-". */
  background-size: 0.6rem 0.125rem, 0.125rem 0.6rem;
  transition: color 160ms var(--ease-out), border-color 160ms var(--ease-out),
    background-color 160ms var(--ease-out);
}

.check__toggle[aria-expanded="true"]::before {
  background-size: 0.6rem 0.125rem, 0 0.6rem;
  border-color: var(--fg);
  color: var(--fg);
}

.check:hover .check__toggle::before,
.check__toggle:focus-visible::before {
  border-color: var(--fg);
  background-color: var(--bg-raised);
  color: var(--fg);
}

.check:hover .check__toggle,
.check__toggle:focus-visible {
  color: var(--fg);
}

/*
  Height is driven from the script, not from a grid track.

  The 0fr/1fr trick was here first and it did not survive: inside a table cell
  the open track resolved to the padding alone, so a row that said it was open
  showed thirty-eight pixels of nothing and clipped the sentence. An explicit
  pixel height, measured and then released back to auto, is duller and it is
  correct at every content length - including the ones a translation will
  produce later. The list is not a table any more, but the reason the script
  measures rather than the stylesheet still holds: auto has no height to
  transition from.

  Setting .style.height from script is not an inline style attribute in the
  markup, so the CSP's style-src stays 'self'; the carousel already drives
  transform the same way.

  HOW TO MEASURE THIS, because the obvious way lies: .check__inner carries a
  260ms height transition, and a browser tab that is not painting FREEZES that
  transition at its start value. Height then reads as the full content height
  while .style.height already says 0px, which looks exactly like a broken
  collapse and is not one. Set .style.transition = "none" on every
  .check__inner and force a reflow before reading any height here. This cost
  one correct fix, reverted on bad numbers, before the cause was found.
*/
.check__inner {
  overflow: hidden;
  transition: height 260ms var(--ease-out);
}

/* 60ch stopped the verification text at 696px inside a row that runs to 1223,
   which put a second ragged edge a third of the way across an item the reader
   had just opened. These answers are one or two sentences each; the two longest
   items are the exception and are set as several short paragraphs rather than
   one block, so no single run of text here is long enough for the measure to be
   doing work. */
.check__inner > p {
  margin: 0;
  max-width: none;
  font-size: var(--f-s);
  line-height: 1.62;
  color: var(--fg-muted);
  padding-block: 0 var(--s-5);
  padding-inline-start: calc(1.15rem + var(--s-2));
}

/* A verification that has to separate two engagements runs to more than one
   paragraph. Only the last carries the block's outer padding; the gap between
   them is the paragraph break itself, not two paddings meeting. */
.check__inner > p + p {
  padding-block-start: var(--s-3);
}

@media (prefers-reduced-motion: reduce) {
  .check__inner {
    transition: none;
  }
}

/* ---------- one flow, in other units ---------- */

/*
  This was a fenced code block. It is arithmetic, not source, and a terminal
  frame around it made the paragraph look like documentation for a tool the
  reader has to install. Rows with the number on the right read as a table of
  ratios, which is what it is.
*/
.ratio__h {
  margin-block: var(--s-6) var(--s-3);
  color: var(--fg-muted);
  font-size: var(--f-s);
}

.ratio {
  max-width: 34rem;
  margin-block: 0 var(--s-8);
}

.ratio > div {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-5);
  padding-block: var(--s-3);
  border-block-end: 1px solid var(--line);
}

.ratio dt {
  font-weight: 400;
  color: var(--fg-muted);
  font-size: var(--f-s);
}

/* The number is the thing being compared down the column, so it gets the mono
   face and the full contrast, and the label stays quiet. */
.ratio dd {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--f-s);
  color: var(--fg);
  white-space: nowrap;
}


/* ---------- numbered deliverables ---------- */

/*
  Ten items, each a thing that is in the document. Numbered because the reader
  is counting what they get for a fixed price, and a bullet does not let them.
*/
.deliv {
  max-width: none;
  list-style: none;
  counter-reset: deliv;
  padding: 0;
  margin-block: var(--s-6) var(--s-12);
}

.deliv li {
  counter-increment: deliv;
  position: relative;
  margin: 0;
  padding: var(--s-4) 0 var(--s-4) var(--s-10);
  border-block-end: 1px solid var(--line);
  line-height: 1.62;
  transition: background 180ms var(--ease-out);
}

.deliv li::before {
  content: counter(deliv, decimal-leading-zero);
  position: absolute;
  inset-inline-start: 0;
  inset-block-start: var(--s-4);
  font-family: var(--mono);
  font-size: var(--f-s);
  color: var(--fg-muted);
}

.deliv li:hover {
  background: var(--tint);
}

/* The same quiet answer to the pointer on the other list types, so a list feels
   like a list everywhere on the site rather than only where it is a control. */
.checks dt {
  transition: color 180ms var(--ease-out);
}

.checks div:hover dt,
.tile__list li,
.flow li {
  transition: color 180ms var(--ease-out);
}

/* ---------- the form ---------- */

/*
  Seven questions instead of a call.

  It posts nowhere. The CSP is form-action 'none', there is no backend, and a
  hosted form service would put a third party on a site whose privacy page says
  in the first line that there are none. So the script reads the fields, builds
  a mailto: and hands it to the reader's own mail client - the same trick the
  address on the contact page already uses, just with a body attached.

  Questions 3 and 4 are the ones that do the work: a list of what people do, and
  what it costs when each one silently breaks. Those two together are a
  risk-ranked flow inventory, which is the thing a discovery call is normally
  held to produce.
*/
.ask {
  max-width: none;
  margin-block: var(--s-20) var(--s-16);
  padding: var(--s-10) var(--s-8);
  border: 1px solid var(--fg);
  background: var(--glass);
  backdrop-filter: blur(var(--glass-blur)) saturate(130%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(130%);
}

@media (max-width: 40rem) {
  .ask {
    padding: var(--s-6) var(--s-5);
  }
}

/* 18ch was 503px against a headline that measures 488px: three per cent of
   headroom, on a line whose width depends on the font actually loading. It sat
   one metric change away from wrapping "application." onto a line of its own.
   24ch is the same decision as the h2 cap above, taken with room to spare. */
.ask__h {
  margin-block: 0 var(--s-3);
  max-width: 24ch;
}

.ask__sub {
  max-width: none;
  color: var(--fg-muted);
  margin-block-end: var(--s-5);
}

/* The page-specific steer, when a page passes one in, sits between the two and
   takes the gap that would otherwise be under .ask__sub. */


/*
  One measure inside the panel, for everything in it.

  Measured at 1440px before this, the panel had FIVE different right edges:
  the panel itself at 1366, the form at 997, the fields at 973, the heading at
  794, the sub at 746, and the notes at 677. The worst of it was that the text
  explaining a field stopped 300px before the field did - the note is narrower
  than the thing it introduces, inside the same box.

  Now the panel's padding sets the measure and nothing else does. Prose, form
  and controls share one edge, and it is the same edge the prose above the panel
  reached once "What is included" was widened.

  The cost, stated rather than hidden: a single-line input for a URL is now
  wider than the URL will ever be, and field width normally hints at expected
  input length. That hint is worth less here than one straight edge down a panel
  a reader is being asked to fill in - and it costs nothing on the two textareas,
  which are where the actual answers get written.
*/
/* The break between the intro and the fields sits here rather than on the
   paragraph above it. It used to be .ask__note--lead:last-of-type, which
   matched nothing: :last-of-type looks for the last <p> in the section, and
   that is the trailing .ask__note below the form. Dead rule, even gaps, no
   separation between reading and filling in. */
.ask__form {
  display: grid;
  gap: var(--s-8);
  max-width: none;
  margin-block-start: var(--s-10);
}

.field {
  display: grid;
  gap: var(--s-3);
  margin: 0;
}

.field label,
.field legend {
  font-size: var(--f-s);
  line-height: 1.5;
  color: var(--fg);
  padding: 0;
}

/* Marked on the three that carry the answer, rather than marking the four that
   do not. An incomplete form beats an unsent one, and the label should say
   which three are worth the effort. */
.req {
  font-family: var(--mono);
  font-size: var(--f-xs);
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--fg-muted);
  margin-inline-start: var(--s-2);
}

.field input[type="text"],
.field textarea {
  font: inherit;
  font-size: var(--f-base);
  color: var(--fg);
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: var(--s-3) var(--s-4);
  width: 100%;
  transition: border-color 180ms var(--ease-out);
}

.field textarea {
  resize: vertical;
  line-height: 1.55;
  min-height: 9rem;
}

.field input[type="text"]:hover,
.field textarea:hover {
  border-color: var(--fg-muted);
}

.field input[type="text"]:focus-visible,
.field textarea:focus-visible {
  border-color: var(--fg);
}

/* The two that carry the answer are visibly the two that carry the answer. */
.field--big textarea {
  min-height: 12rem;
}

.field--choice {
  border: 0;
  padding: 0;
  margin: 0;
  display: grid;
  gap: var(--s-3);
}

.field--choice label {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  cursor: pointer;
  transition: color 160ms var(--ease-out);
}

.field--choice label:hover {
  color: var(--fg);
}

.field--choice input[type="radio"] {
  accent-color: var(--fg);
  width: 1rem;
  height: 1rem;
  flex: none;
}

.field[data-invalid] input[type="text"],
.field[data-invalid] textarea {
  border-color: var(--fg);
  border-width: 2px;
}

.ask__actions {
  margin: 0;
}

.ask__err {
  margin: 0;
  font-size: var(--f-s);
  color: var(--fg);
  min-height: 1.5em;
}

.ask__err:empty {
  min-height: 0;
}

/*
  Shown only when the script did not run. site.js puts has-js on the root, so
  the default state of the page is the one that works without it: the fallback
  visible, the submit button hidden, nothing promising an action that cannot
  happen.
*/
.ask__nojs {
  font-size: var(--f-s);
  color: var(--fg-muted);
  max-width: none;
  margin-block: var(--s-6) 0;
}

:root.has-js .ask__nojs {
  display: none;
}

:root:not(.has-js) .ask__actions {
  display: none;
}

/*
  The one-line steer for a page whose reader is not the reader the questions
  were written for.

  It used to be authored in the page body immediately before the include, which
  put it outside the panel: measured on /coverage/ it sat 139px above the glass,
  a sentence about the form floating above the frame the form is drawn in and
  reading as the tail of the section before. The negative bottom margin here was
  an attempt to drag it back down, which closed the gap without ever putting it
  inside anything.

  It is passed into the include as a variable now and renders inside the panel,
  so the margins are ordinary.
*/
.ask__lead {
  max-width: none;
  color: var(--fg-muted);
  font-size: var(--f-s);
  margin-block: 0 var(--s-5);
}

.ask__note {
  font-size: var(--f-s);
  color: var(--fg-muted);
  max-width: none;
  margin-block: var(--s-6) 0;
}

/* The two notes that sit above the fields rather than below the button. Same
   voice as the others, but they lead into the form instead of trailing it, so
   they carry the spacing of an intro rather than of a footnote. */
.ask__note--lead {
  margin-block: 0 var(--s-5);
}

/*
  Sits under the button rather than after the form, because it answers a
  question the button creates: the page hands a mailto: to the mail client and
  then cannot know what happened, so there is no confirmation to show. It is
  quieter than the button and closer to it than the notes below are.

  Full text colour, which none of the other notes get. It used to be --fg-muted
  like the rest, and that was one decision too few: the notes are advice you can
  skip, and this is the only line on the page that resolves "did that work?" for
  someone who has just pressed the button and seen nothing happen. Same size,
  same width, same voice - it is louder only where being missed would cost
  something.
*/
.ask__after {
  font-size: var(--f-s);
  color: var(--fg);
  max-width: none;
  margin-block: var(--s-4) 0;
}

/* =========================================================
   The material, carried through every page

   Scoped to .qa, which only the main test automation page sets. The three
   subpages keep the earlier treatment until this one is agreed.

   The problem: the top of the page is made of a material - glass, blur, a
   gradient rim, the figure showing through - and everything from the checklist
   down was made of default browser shapes. Flat next to glass does not read as
   restraint, it reads as unfinished.

   Two rules the whole block follows.

   Light, not dark. Every hover state here brightens and frosts. The first
   attempt darkened the surface under the pointer while the control inside it
   brightened, which is two answers to one gesture and reads as a fault.

   Soft, not sharp - except the tiles. Right angles everywhere made the page
   read as a document rather than a product. The tiles keep their corners
   because that is where the hard edge is doing work; everything below is
   rounded to the same small radius so the page has one shape language.
   ========================================================= */

.page {
  --r: 10px;
  --r-sm: 6px;
  --r-lg: 14px;

  /*
    Black is reserved for type.

    Every non-text mark on this page - the checklist toggles, a focused field's
    edge, the panel outlines, the toggle box - was drawn in --fg, the same ink as
    the words. At the size those things are drawn, full black reads as heavier
    than the sentence beside it, which puts the emphasis on the furniture. This
    is one step off black: still unambiguous against the ground, still clearly
    darker than muted text, and it stops competing with the reading.
  */
  --edge: #3c424a;
}

/* ---------- bands: every block answers the pointer ---------- */

/*
  One <section class="band"> per h2, wrapped at build time so the wrapper exists
  for a reader with no scripts. The band is what hover and the entrance attach
  to; without it the stylesheet has only loose paragraphs to grab.
*/
.page .band {
  position: relative;
  padding: var(--s-4) var(--s-6) var(--s-6);
  margin-inline: calc(var(--s-6) * -1);
  border-radius: var(--r-lg);
  transition: background 300ms var(--ease-out),
    backdrop-filter 300ms var(--ease-out);
}

/*
  Frosting, not shading.

  The blur is what the tiles already do at rest, so a band under the pointer
  becomes the same material as the cards at the top of the page rather than a
  grey rectangle. backdrop-filter is only ever active on one band at a time,
  which is what keeps it affordable over a WebGL canvas.
*/
.page .band:hover {
  background: var(--glass-hover);
  backdrop-filter: blur(var(--glass-blur)) saturate(130%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(130%);
}

/* The rim, same mask-composite trick the cards use: paint a gradient over the
   whole box, subtract the padding box, and 1px of frame is left. Invisible
   until the pointer arrives, so the page at rest is still only type and rules. */
.page .band::before {
  content: "";
  position: absolute;
  inset: 0;
  padding: 1px;
  border-radius: inherit;
  background: linear-gradient(
    180deg,
    var(--rim-hi) 0%,
    var(--rim-lo) 20%,
    transparent 45%,
    transparent 55%,
    var(--rim-lo) 80%,
    var(--rim-hi) 100%
  );
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  mask-composite: exclude;
  pointer-events: none;
  opacity: 0;
  transition: opacity 300ms var(--ease-out);
}

.page .band:hover::before {
  opacity: 0.85;
}

@media (max-width: 48rem) {
  .page .band {
    padding-inline: var(--s-4);
    margin-inline: calc(var(--s-4) * -1);
  }
}

/*
  Arrival — one system, ported from the brehon landing site.

  The shape is that of components/Reveal.tsx there: three phases rather than
  two. An element is VISIBLE by default, so a reader with no scripts, a failed
  script or a browser without IntersectionObserver gets the finished page.
  Script arms it - hides it - only at the moment it can also guarantee to show
  it again, and anything already in the viewport at load is shown in the same
  breath rather than waiting for a scroll that has already happened.

  A transition rather than a keyframe animation, which is the one place this
  departs from the original. An animation with fill-mode both holds its
  from-state until it actually runs, so a backgrounded tab or a paused
  compositor leaves the block at opacity 0 for good. A transition has no fill:
  the moment .is-in lands the element is visible, and only the movement is
  optional.

  The numbers come straight from that project's motion tokens, which is most of
  why it reads as calm: 0.62s where this site had 0.9, 16px where it had 22, and
  one settle curve shared by everything that moves.
*/
@media (prefers-reduced-motion: no-preference) {
  /*
    Hidden only where the script has taken responsibility for showing it again.

    The first version hid every block behind a .has-js flag on the root, set at
    the top of site.js. That flag says "scripts run here", which is not the same
    claim as "this element will be revealed" - and when the selector below and
    the one in the script drifted apart during a rename, .has-js was still true,
    nothing matched in the script, and every page rendered blank.

    .will-arrive is written by the script onto the elements it is actually
    observing, one at a time. Now the hiding and the revealing are driven by the
    same list. If the script never runs, never reaches this job, or looks for
    the wrong selector, no element is hidden in the first place - the failure is
    a page with no animation instead of a page with no content.
  */
  .page .band.will-arrive,
  .page .ask.will-arrive,
  .page .tile.will-arrive,
  .page .tiles__note.will-arrive,
  .foot-col.will-arrive {
    opacity: 0;
    transform: translateY(var(--reveal-shift));
    transition: opacity var(--dur-reveal) var(--ease-brand),
      transform var(--dur-reveal) var(--ease-brand);
    /*
      Connected groups arrive in sequence. The four tiles share a scroll
      position, so without a delay they land as one slab. site.js writes
      --reveal-delay as index x --stagger within each group; everything else
      falls back to 0 and arrives on its own scroll position.
    */
    transition-delay: var(--reveal-delay, 0ms);
  }

  .page .band.will-arrive.is-in,
  .page .ask.will-arrive.is-in,
  .page .tile.will-arrive.is-in,
  .page .tiles__note.will-arrive.is-in,
  .foot-col.will-arrive.is-in {
    opacity: 1;
    transform: none;
  }

}

/* ---------- the checklist, in the page's own material ---------- */

/* The toggle: rounded, and it answers in the hue because it is the control.
   Nothing else in the row competes to be pressed. */
.page .check__toggle::before {
  border-radius: var(--r-sm);
  border-color: var(--line);
  background-color: transparent;
}

.page .check:hover .check__toggle::before,
.page .check__toggle:focus-visible::before {
  border-color: var(--accent);
  color: var(--accent);
  background-color: var(--accent-wash);
}

.page .check:hover .check__toggle,
.page .check__toggle:focus-visible {
  color: var(--accent);
}

.page .check__toggle[aria-expanded="true"]::before {
  border-color: var(--edge);
  color: var(--edge);
}

/* An open item is one block with its answer: the surface runs behind both, and
   the hairline between them goes. */
.page .check:has(.check__body:not([data-collapsed])) {
  background: color-mix(in srgb, var(--bg-raised) 70%, transparent);
  border-radius: var(--r-sm);
}

/* ---------- disclosures ---------- */

.page details {
  border-radius: var(--r);
  padding-inline: var(--s-5);
  margin-inline: calc(var(--s-5) * -1);
  transition: background 260ms var(--ease-out);
}

.page details:hover {
  background: var(--glass-hover);
  backdrop-filter: blur(var(--glass-blur)) saturate(130%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(130%);
}

/*
  The content slides as it opens rather than appearing at full height.

  ::details-content already animates its block-size where interpolate-size is
  supported; on its own that is a shutter. Moving the content a little at the
  same time is what makes it read as the panel opening rather than the page
  reflowing around a hole.
*/
@supports (interpolate-size: allow-keywords) {
  @media (prefers-reduced-motion: no-preference) {
    .page details > *:not(summary) {
      transition: transform 260ms var(--ease-out);
      transform: translateY(-6px);
    }

    .page details[open] > *:not(summary) {
      transform: none;
    }
  }
}

/*
  The questions inside a disclosure were bold paragraphs, which is what markdown
  gives you and not what a question is. They are headings in everything but the
  tag, so they get a heading's spacing and a rule to sit against.
*/
.page details p > strong:only-child {
  display: block;
  font-weight: 500;
  font-size: var(--f-m);
  letter-spacing: -0.018em;
  color: var(--fg);
  padding-block-start: var(--s-5);
  border-block-start: 1px solid var(--line-soft);
}

.page details > p:first-of-type > strong:only-child {
  padding-block-start: 0;
  border-block-start: 0;
}

/*
  A disclosure that carries mechanics rather than an argument.

  The distinction matters for type size. The other disclosures on the site hold
  material a buyer might read instead of the main text - the Q&A, how a flow is
  counted - so they are set at reading size. This one holds branch protection,
  --no-verify and the audit log: material an engineer will read once, in
  addition to the summary above it, never in place of it. Smaller and quieter is
  what tells a reader they are not missing an argument by leaving it closed.
*/
.mech > *:not(summary) {
  font-size: var(--f-s);
  color: var(--fg-muted);
  max-width: none;
}

/* ---------- the form ---------- */

.page .ask {
  padding: var(--s-12) var(--s-10);
  border-radius: var(--r-lg);
  border-color: var(--edge);
}

@media (max-width: 40rem) {
  .page .ask {
    padding: var(--s-6) var(--s-5);
  }

}

/*
  Numbered like everything else on the page. The tiles number their positions
  and the process numbers its steps; the form was the one block with no
  numbering at all, which is most of why it read as imported from somewhere
  else. Counters rather than markup, so questions can be reordered in the
  template without renumbering by hand.
*/
.page .ask__form {
  counter-reset: q;
  gap: var(--s-8);
}

.page .field {
  counter-increment: q;
  position: relative;
  padding: var(--s-4) var(--s-5);
  border-radius: var(--r);
  transition: background 220ms var(--ease-out);
}

.page .field:hover,
.page .field:focus-within {
  background: color-mix(in srgb, var(--bg-raised) 45%, transparent);
}

/* The number sits on the label's line rather than in a margin of its own. In a
   column it still lines up, and the question no longer starts a third of the
   way across the field. */
/*
  The question's own label, not the options underneath it.

  In a fieldset the option labels are direct children too, so "> label" put the
  question number in front of every radio - "07 Yes", "07 No", "07 Not sure".
  The options are excluded by name below rather than by relying on order.
*/
.page .field:not(.field--choice) > label,
.page .field > legend {
  display: block;
  padding-inline-start: var(--s-8);
  position: relative;
}

.page .field:not(.field--choice) > label::before,
.page .field > legend::before {
  content: counter(q, decimal-leading-zero);
  position: absolute;
  inset-inline-start: 0;
  font-family: var(--mono);
  font-size: var(--f-xs);
  color: var(--fg-muted);
  transition: color 180ms var(--ease-out);
}

.page .field:not(.field--choice):focus-within > label::before,
.page .field:focus-within > legend::before {
  color: var(--accent);
}

/*
  Fields as wells, not as boxes laid on top.

  A 1px rectangle on a flat fill is the 2003 shape. Sinking the field into the
  panel - darker ground, a shadow at the top edge where light would not reach,
  a brighter line at the bottom - gives it a position in the same space the
  glass panels occupy instead of floating above it.
*/
.page .field input[type="text"],
.page .field textarea {
  background: color-mix(in srgb, var(--fg) 3.5%, transparent);
  border: 1px solid transparent;
  border-block-end-color: var(--line);
  border-radius: var(--r-sm);
  box-shadow: inset 0 1px 2px rgba(11, 12, 14, 0.07);
  padding: var(--s-3) var(--s-4);
  transition: background 180ms var(--ease-out), border-color 180ms var(--ease-out),
    box-shadow 180ms var(--ease-out);
}

.page .field input[type="text"]:hover,
.page .field textarea:hover {
  background: color-mix(in srgb, var(--fg) 5%, transparent);
  border-block-end-color: var(--edge);
}

/* The radio is a control, so it takes the hue. The mark it draws when checked
   is the same blue as the button that sends the form. */
.page .field--choice input[type="radio"] {
  accent-color: var(--accent);
}

/* The focused well lights along its bottom edge rather than gaining a ring:
   unmistakable, and it does not shift the layout. */
.page .field input[type="text"]:focus-visible,
.page .field textarea:focus-visible {
  outline: none;
  background: color-mix(in srgb, var(--fg) 5%, transparent);
  border-block-end: 2px solid var(--accent);
  box-shadow: inset 0 1px 3px rgba(11, 12, 14, 0.1);
}

.page .field[data-invalid] input[type="text"],
.page .field[data-invalid] textarea {
  border-block-end: 2px solid var(--edge);
  background: color-mix(in srgb, var(--fg) 7%, transparent);
}

.page .field--choice {
  padding-block-end: var(--s-4);
}

/*
  A <legend> ignores its fieldset's block-start padding.

  Every .field gets padding: var(--s-4) var(--s-5) and a background on hover and
  focus-within. On a text question the label sits 19px below the top of that
  highlight. On the two radio questions the legend sat at exactly the top edge -
  measured, fieldset.top and legend.top were the same pixel - because the
  browser renders a legend in the block-start border area rather than inside the
  padding box, whatever display the fieldset is given.

  So the highlight arrived tight against the question text, with the corner
  radius arcing across the start of it. It looked like the panel was clipping
  the question rather than containing it, and only on the two questions that
  happen to use a fieldset.

  Padding on the legend itself puts the 19px back where the fieldset could not.
  The counter in ::before is statically positioned, so it moves with the text
  and stays on the line it numbers.
*/
.page .field--choice > legend {
  padding-block-start: var(--s-4);
  margin-block-end: var(--s-1);
}

/*
  Options, not bands.

  Boxed rows with their own padding read as three separate controls stacked in a
  panel, which is more furniture than a yes/no/not sure deserves. They sit in the
  text column with the question, take a hairline between them instead of a fill,
  and answer the pointer only where the pointer is.
*/
.page .field--choice > label {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin: 0 0 0 var(--s-8);
  padding: var(--s-2) var(--s-2) var(--s-2) 0;
  border-radius: 0;
  border-block-start: 1px solid var(--line-soft);
  color: var(--fg-muted);
  transition: color 160ms var(--ease-out);
}

.page .field--choice > label:first-of-type {
  border-block-start: 0;
}

.page .field--choice > label:hover {
  background: none;
  color: var(--fg);
}

/* The chosen one is the only line in full ink. */
.page .field--choice > label:has(input:checked) {
  color: var(--fg);
}

/* The button is a solid slab, which makes it the largest non-text mark on the
   page. At full black it out-weighed the headline above it. */
/* The scoped rule now only rounds the corner. It used to restate the fill as
   --edge, which was correct while the page had no colour in it and is exactly
   what would have quietly kept the send button grey. */
.page .btn {
  border-radius: var(--r);
}

/* ---------- one question at a time ---------- */

/*
  The form is seven questions and it was taking half the page before anyone had
  answered one. Folded, it is a single question and a line saying how many are
  left, which is the difference between a task and a page of admin.

  Folding is a class the script writes, never a default in the markup: with no
  script every question is present and the form works as a form.
*/
.page .field--folded {
  display: none;
}

/*
  One edge for the whole form.

  Each .field draws its own hover well, which means its padding insets the
  question from the form's own left edge. The meta line, the submit button and
  the error message were all sitting on the outer edge instead, so the column
  had two left margins and the eye could see both. They are inset by the same
  --s-5 here, and everything in the form now starts on one line.
*/
.page .ask__meta,
.page .ask__actions,
.page .ask__err,
.page .ask__about {
  padding-inline: var(--s-5);
}

.page .ask__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3) var(--s-6);
}

/*
  The tail is one unit, so it is spaced like one.

  The form is a grid with a 38px gap, which is the right distance between two
  questions and far too much between a progress line, the button it belongs to
  and the message that answers it. Those three are pulled back towards each
  other; the gap above them, which is the actual break in the form, is left
  alone.
*/
.page .ask__actions {
  margin-block-start: calc(var(--s-4) - var(--s-8));
}

.page .ask__err {
  margin-block-start: calc(var(--s-3) - var(--s-8));
}

.page .ask__meta[hidden] {
  display: none;
}

.page .ask__progress {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--f-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--fg-muted);
}

/*
  The engagement the reader arrived from, when they arrived from a card.

  Set as a line of the form rather than a hidden field, because the form's whole
  argument is that you can see what it will send before it sends it.
*/
.page .ask__about {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--f-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--accent);
}

.page .ask__about[hidden] {
  display: none;
}

/* Reading the whole form before starting it is a legitimate way to decide
   whether to start it, so the way out is a control rather than a scroll. It
   folds back too: an expand with no matching collapse is a one-way door, and
   the reader who opens all seven to look at them has no way back to the one
   question they were answering. */
.page .ask__all {
  appearance: none;
  font: inherit;
  font-size: var(--f-s);
  cursor: pointer;
  background: transparent;
  border: 0;
  padding: 0;
  /* Nothing: it is in a row now, not stacked under the progress line. A
     full-width block button also centred its own label, which is why the words
     sat in the middle of a column that is flush left everywhere else. */
  margin: 0;
  text-align: start;
  color: var(--fg-muted);
  text-decoration: underline;
  text-underline-offset: 0.22em;
  text-decoration-color: var(--line);
  transition: color 180ms var(--ease-out), text-decoration-color 180ms var(--ease-out);
}

.page .ask__all:hover {
  color: var(--accent);
  text-decoration-color: currentColor;
}

/*
  Narrow screens: one edge, closer in.

  This has to sit after every rule it narrows. It was written next to the .ask
  padding further up, which is before .page .field in this file, and at equal
  specificity the later rule wins - so the desktop inset was still in force on a
  phone and the form had two left margins there after all.
*/
@media (max-width: 40rem) {
  .page .field {
    padding-inline: var(--s-3);
  }

  .page .ask__meta,
  .page .ask__actions,
  .page .ask__err,
  .page .ask__about {
    padding-inline: var(--s-3);
  }
}

/* ---------- what gets composed ---------- */

/*
  The page's whole argument is that its claims can be checked. This is that
  applied to itself: the exact text the mail client will receive, built from the
  fields as they are typed and shown before anything is handed anywhere. It
  stays hidden until there is something to show, so an empty form is not
  followed by an empty box.
*/
.page .peek {
  margin-block-start: var(--s-8);
  border: 1px solid var(--line);
  border-radius: var(--r);
  overflow: hidden;
  background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
}

.page .peek[hidden] {
  display: none;
}

.page .peek__h {
  margin: 0;
  padding: var(--s-3) var(--s-5);
  border-block-end: 1px solid var(--line);
  font-family: var(--mono);
  font-size: var(--f-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--fg);
}

.page .peek__note {
  text-transform: none;
  letter-spacing: 0;
  color: var(--fg-muted);
}

.page .peek__body {
  margin: 0;
  border: 0;
  border-radius: 0;
  background: none;
  padding: var(--s-5);
  max-height: 22rem;
  overflow: auto;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  font-size: var(--f-xs);
  line-height: 1.6;
  color: var(--fg-muted);
}

/* ---------- the rest of the page, softened ---------- */

.page .callout,
.page .checks-more,
.page .ratio,
.page pre {
  border-radius: var(--r);
}

.page .checks dt,
.page .deliv li {
  border-radius: var(--r-sm);
}

@media print {
  :root {
    --bg: #fff;
    --bg-raised: #fff;
    --fg: #000;
    --fg-muted: #333;
    --line: #999;
  }

  .bg,
  .site-head nav,
  .skip,
  .next {
    display: none;
  }

  .site-head {
    position: static;
    backdrop-filter: none;
  }

  body {
    font-size: 11pt;
  }

  main {
    padding-block: 0;
  }

  main > *,
  .callout {
    max-width: none;
  }

  details[open] > *,
  details > summary {
    animation: none;
  }

  details:not([open]) > *:not(summary) {
    display: revert;
  }

  a {
    text-decoration: underline;
  }

  main a[href^="/"]::after {
    content: " (" attr(href) ")";
    font-size: 0.85em;
    color: #555;
  }

  h2 {
    break-after: avoid;
  }

  tbody tr {
    break-inside: avoid;
  }
}

/* =========================================================
   TILES: a service, a summary, and a page behind it

   Four of them on /qa-build/. The whole tile is one link rather than a word
   inside it: there is nothing else in the tile competing to be clicked, and a
   300px target beats a 90px one for everybody, not only for people who find
   small targets hard.

   They are laid out as a grid of spans inside an <a>, which is legal HTML5 and
   is what lets the entire block be the interactive element while the heading,
   the body and the arrow stay separate boxes.
   ========================================================= */

/*
  Four across, and four is the point.

  These are read the way price positions are read - down the columns, comparing
  one line against the same line in the next tile - so auto-fit is wrong here:
  it would reflow to three and one at some widths and destroy the comparison.
  Four explicit columns hold until the viewport genuinely cannot carry them,
  then two, then one.

  The tiles now carry a duration, a situation and up to nine deliverables each,
  which is a lot of text for a 270px column. The type inside them is set one
  step down and tighter than body copy for exactly that reason: the row has to
  survive, so the column has to be economical.
*/
.tiles {
  max-width: none;
  list-style: none;
  padding: 0;
  margin-block: var(--s-10) var(--s-16);
  display: grid;
  gap: var(--s-3);
  grid-template-columns: repeat(4, 1fr);
}

@media (max-width: 74rem) {
  .tiles {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 40rem) {
  .tiles {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 36rem) {
  .tiles {
    grid-template-columns: 1fr;
  }
}

.tile {
  margin: 0;
}

/* Flex rather than a fixed row template: the tiles no longer carry the same
   number of parts - 01 has no situation line - and a template would either
   reserve an empty row or collapse the wrong one. The list takes the slack so
   the closing line and the arrow stay pinned to the bottom of every tile. */
.tile__link {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  height: 100%;
  padding: var(--s-5) var(--s-4) var(--s-4);
  text-decoration: none;
  color: inherit;
  outline: 1px solid var(--line);
  background: var(--glass);
  backdrop-filter: blur(var(--glass-blur)) saturate(130%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(130%);
  transition: background 220ms var(--ease-out), transform 220ms var(--ease-out),
    outline-color 220ms var(--ease-out);
}

.tile__link:hover {
  background: var(--glass-hover);
  outline-color: var(--fg-muted);
  transform: translateY(-2px);
}

/* The pointer is a pointer over the whole card, since the whole card is the
   target. Without this only the four words at the bottom said so. */
.tile__link:has(.tile__go) {
  cursor: pointer;
}

.tile__link:active {
  transform: translateY(0);
}

.tile__n {
  font-family: var(--mono);
  font-size: var(--f-xs);
  color: var(--fg-muted);
}

.tile__h {
  font-size: var(--f-m);
  font-weight: 500;
  line-height: 1.15;
  letter-spacing: -0.024em;
  text-wrap: balance;
}

/* Duration first, because it is the question asked before "what is in it".
   Mono so the four line up as a column when the tiles are read across. */
.tile__when {
  font-family: var(--mono);
  font-size: var(--f-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--fg);
}

/* The situation, not the deliverable. A buyer recognises themselves here or
   moves on, and that is a faster read than any feature list. */
/* The situation, not the deliverable. A buyer recognises themselves here or
   moves on, and that is a faster read than any feature list.

   It used to be set in full ink behind a vertical rule, which made a one-line
   aside look like a pulled quote - the loudest thing in a card whose actual
   argument is the list underneath. Now it reads as what it is: the subtitle to
   the name above it. */
.tile__for {
  font-size: var(--f-xs);
  line-height: 1.45;
  color: var(--fg-muted);
  margin-block-end: var(--s-1);
}

/* The list is what makes these comparable: same shape, same order, one line per
   thing included. Markers are drawn rather than inherited, because the list
   sits inside a link and a default disc would inherit the link's colour and
   read as part of the text. */
.tile__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--s-3);
  align-content: start;
  /* Absorbs the height difference between tiles of unequal length. */
  flex: 1 1 auto;
}

.tile__list li {
  margin: 0;
  position: relative;
  padding-inline-start: var(--s-3);
  color: var(--fg-muted);
  font-size: var(--f-xs);
  line-height: 1.42;
}

.tile__list li::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  inset-block-start: 0.62em;
  width: 5px;
  height: 1px;
  background: var(--fg-muted);
}

/* The line that answers the objection rather than describing the work. Set off
   above the arrow so it is the last thing read before the tile is clicked. */
.tile__note {
  font-size: var(--f-xs);
  line-height: 1.42;
  color: var(--fg-muted);
  border-block-start: 1px solid var(--line);
  padding-block-start: var(--s-3);
}

/*
  The card's destination, and the card's whole click target.

  The link itself is short - four words and an arrow - so that is what a screen
  reader announces and what the keyboard lands on. The ::after below stretches
  it over the entire card, which is what makes a press anywhere in those 900
  pixels count. It costs text selection inside the card, which is the accepted
  trade for this pattern and the right one here: these cards are read, then
  pressed, and nobody copies a bullet out of one.
*/
.tile__go {
  align-self: start;
  margin-block-start: var(--s-1);
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--f-xs);
  font-family: var(--mono);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--fg-muted);
  text-decoration: none;
  transition: transform 220ms var(--ease-out), color 220ms var(--ease-out);
}

.tile__go .arrow {
  width: 0.85em;
  height: 0.85em;
  flex: none;
}

/*
  Two actions at the foot of the card, and the card itself is the first of them.

  "Read in full" is stretched over the whole card: the card is a service, the
  page is that service written out, and a press anywhere on it should go there.
  That is what the card did before and what people reach for. The underline at
  the foot is the label for it, not a separate smaller target.

  "Get a price" sits inside that stretched area and has to win where it is, so it
  is lifted onto its own stacking context. Its own hit area is the words and the
  arrow; everything around them belongs to the card.

  I tried it with neither link stretched, on the reasoning that one press cannot
  mean two destinations. It reads fine and it is wrong: the card stops answering
  the press it visibly invites, and nothing about a bordered clickable-looking
  panel tells you to aim at the last line of it.
*/
.tile__acts {
  display: grid;
  gap: var(--s-3);
  margin-block-start: var(--s-4);
  padding-block-start: var(--s-4);
  border-block-start: 1px solid var(--line-soft);
}

.tile__more {
  justify-self: start;
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--f-s);
  color: var(--fg);
  text-decoration: underline;
  text-underline-offset: 0.22em;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--line);
  transition: text-decoration-color 200ms var(--ease-out), transform 220ms var(--ease-out);
}

.tile__more .arrow {
  width: 0.9em;
  height: 0.9em;
  flex: none;
  transition: transform 220ms var(--ease-out);
}

.tile__more:hover {
  text-decoration-color: currentColor;
}

.tile__more:hover .arrow {
  transform: translateX(3px);
}

/* The whole card is the target for this one. */
.tile__more::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
}

/* The action row must stay unpositioned. Giving it position: relative made it
   the containing block for the stretched ::after above, so the overlay covered
   the row instead of the card and a press on the card body hit nothing. */
.tile__go {
  position: relative;
  z-index: 1;
}

/* The outline belongs on the card, because the card is what is being pressed. */
.tile__more:focus-visible {
  outline: none;
}

.tile__link:has(.tile__more:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* Pressing anywhere on the card goes to the page, so the card has to look like
   it knows that. */
.tile__link:has(.tile__more) {
  cursor: pointer;
}

.tile__link:hover .tile__more {
  text-decoration-color: currentColor;
}

.tile__link:hover .tile__more .arrow {
  transform: translateX(3px);
}

.tile__link:hover .tile__go {
  transform: translateX(5px);
  color: var(--accent);
}

/* The one thing true of all four, said once under the grid rather than four
   times inside it. */
.tiles__note {
  max-width: none;
  /* Pulls back most of the grid's bottom margin so the note reads as part of
     the block above it rather than as an orphan paragraph. */
  margin-block: calc(var(--s-5) - var(--s-16)) var(--s-16);
  font-size: var(--f-s);
  color: var(--fg-muted);
}

/* ---------- breadcrumb on the detail pages ---------- */

.crumb {
  font-size: var(--f-s);
  color: var(--fg-muted);
  margin-block: 0 var(--s-6);
}

.crumb a {
  color: var(--fg-muted);
  text-decoration-color: transparent;
  transition: color 180ms var(--ease-out), text-decoration-color 180ms var(--ease-out);
}

.crumb a:hover {
  color: var(--fg);
  text-decoration-color: currentColor;
}

.crumb span[aria-hidden] {
  margin-inline: var(--s-2);
}
