/* ==========================================================================
   COMPONENTS — MVP inventory (docs/ANALYSIS §3)
   Every rule references theme tokens only (PRINCIPLES §2).
   One section per component; split into files when this grows painful.
   ========================================================================== */

/* --------------------------------------------------------------------
   Surfaces — background roles for blocks (ANALYSIS §4.1 rule 2)
   Applied on the block wrapper by the page composition, never nth-child.
   -------------------------------------------------------------------- */
.surface-base    { background: var(--color-surface);         color: var(--color-text); }
.surface-alt     { background: var(--color-surface-alt);     color: var(--color-text); }
.surface-brand   { background: var(--color-primary);         color: var(--color-text-on-primary); }
.surface-inverse { background: var(--color-surface-inverse); color: var(--color-text-on-inverse); }

/* --------------------------------------------------------------------
   Eyebrow — the small pill label above a section heading.
   Cheap to render, disproportionately effective: it tells the reader
   "a new topic starts here" before they read a word.
   -------------------------------------------------------------------- */
/* The chrome is tokenised because the eyebrow is pure identity: same
   markup, same slot above every heading, but whether it arrives as a
   bordered chip with a dot (drga) or as a bare tracked-out word (ingrade)
   is a theme's voice, not a structural fact. Defaults are the chip, so
   themes that say nothing keep it. */
.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--eyebrow-padding, var(--space-1) var(--space-3));
  border: var(--eyebrow-border-width, var(--border-width)) solid var(--color-border);
  border-radius: var(--radius-full);
  background: var(--eyebrow-surface, var(--color-surface));
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  letter-spacing: var(--eyebrow-tracking, 0.04em);
  text-transform: uppercase;
  color: var(--eyebrow-ink, var(--color-primary-700));
  line-height: var(--leading-ui, inherit);
}

/* The dot is the smallest place a second brand colour can show itself,
   and it appears above nearly every block — so a two-colour brand reads
   as two-colour from the first screen. `display`, not size, turns it off:
   a zero-width dot still occupies a flex slot and keeps its share of the
   gap, which reads as a typo-sized indent before the word. */
.eyebrow::before {
  content: "";
  display: var(--eyebrow-dot, inline-block);
  width: 0.4em;
  height: 0.4em;
  border-radius: var(--radius-full);
  background: var(--color-secondary);
}

/* A lead is muted against the page, which on a coloured slab means a
   muted brown *on* brown — it measured 1.09, all but invisible, on both
   CTA bands. There is no muted counterpart to `-on-primary` and inventing
   one would be a token that passes in some themes and not others, so on
   these surfaces the lead simply takes the surface's own text colour. Its
   size already does the work of setting it below the heading. */
.surface-brand .lead,
.surface-inverse .lead {
  color: inherit;
}

/* Both dark surfaces, and `.surface-inverse` has to be here rather than
   above. It used to sit at the top of the `.lead` selector list — a
   comment had been inserted between it and the rest of that list, which
   is invisible when reading and left the selector attached to the wrong
   rule. So the inverse eyebrow took `color: inherit` and nothing else,
   keeping the light `--color-surface` pill from the base rule underneath
   near-white text: measured 1.12, which is not a contrast failure so much
   as a blank pill. On `surface-brand`, which was in the right rule, the
   same component measured 21.

   *A selector list is one statement. A comment placed inside one does not
   split it, and the eye reads it as though it does.* */
/* Slotted for the same reason the button grew --btn-brand-*: this
   override was written for the DEFAULT eyebrow, an outlined chip, and
   turning that into a currentColor outline on a dark slab is right. A
   theme whose eyebrow is a filled chip with no border (vitarent) fell
   through it to a bare word — border gone by the theme's hand,
   background gone by this rule's. Such a theme states what its chip
   does on a brand or inverse slab; everyone else keeps the outline. */
.surface-brand .eyebrow,
.surface-inverse .eyebrow {
  background: var(--eyebrow-brand-surface, transparent);
  border-color: color-mix(in srgb, currentColor 25%, transparent);
  color: var(--eyebrow-brand-ink, inherit);
}

.surface-inverse .eyebrow::before,
.surface-brand .eyebrow::before {
  background: currentColor;
}

/* --------------------------------------------------------------------
   Carousel — a row you page through

   Still the same cards in the same skeleton; the only new thing is that
   more of them fit than the screen shows. Testimonials are the case it was
   built for: nobody puts six on a homepage, but a client with six wants
   all six reachable.

   A scroll container first, buttons second. Without JS it is a row that
   scrolls — a touch screen swipes it, a trackpad flicks it, a keyboard
   tabs through the cards and the browser scrolls to each. The buttons are
   an affordance for a mouse, which is the one input with no native gesture
   for horizontal scroll, and they are added by the script rather than
   sitting in the markup doing nothing.
   -------------------------------------------------------------------- */
.carousel__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  gap: var(--space-6);
  overflow-x: auto;
  /* Spelled out, because it does not stay `visible` on its own: when one
     axis is a scrolling value, `visible` on the other computes to `auto`.
     So a row asking only to scroll sideways silently gained a vertical
     scroll as well, and anything a pixel too tall inside it — an unrevealed
     card still carrying its 24px offset — became a drag nobody wrote.
     Nothing legitimately overflows this box vertically, so clipping it
     costs nothing and stops the next such thing from getting through. */
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  /* The page must not start scrolling sideways when the track runs out. */
  overscroll-behavior-x: contain;
  /* The bar would sit under the cards and count as content height, moving
     the block every time the count changes. The buttons and the snap say
     the row scrolls; a scrollbar saying it again is noise. */
  scrollbar-width: none;
  /* Cards lift 4px on hover and cast a shadow — an overflow container
     clips both without this. */
  padding-block: var(--space-2);
}

.carousel__track::-webkit-scrollbar {
  display: none;
}

.carousel__track > * {
  scroll-snap-align: start;
}

/* Drag to scroll. The cursor is the only thing that announces the row can
   be pushed, so it is shown to the pointer that needs telling — a thumb
   and a trackpad already know, and neither has a cursor to show it to.
   `is-draggable` follows the same measurement that decides whether the
   buttons exist, so a row with nowhere to go never offers the grip. */
@media (hover: hover) and (pointer: fine) {
  .carousel.is-draggable .carousel__track {
    cursor: grab;
  }
}

/* Not in the query above: the class is only ever set for a mouse, and a
   touchscreen laptop with a mouse plugged in would otherwise drag with
   snap still fighting it. */
.carousel.is-dragging .carousel__track {
  cursor: grabbing;
  /* Snap would pull the row to the nearest card under the hand — right at
     the end of a gesture, wrong during one. Off while dragging, and
     putting it back is what settles the row where it was let go. */
  scroll-snap-type: none;
  /* Every frame of a 1:1 drag would otherwise start an animation towards
     somewhere the hand has already moved on from. */
  scroll-behavior: auto;
  user-select: none;
}

@media (min-width: 48rem) {
  /* Two per view, exactly filling the row: the block looks identical to
     the plain pair it replaced whenever there are only two. */
  .carousel__track {
    grid-auto-columns: calc((100% - var(--space-6)) / 2);
  }
}

/* Below the row and flush left, where the cards and the heading start.
   Overlaid on the sides was the other option and it loses: these cards are
   text, so a button parked at their vertical centre sits on top of a
   sentence — which is the one thing an image carousel does not have to
   worry about. */
.carousel__nav {
  display: flex;
  gap: var(--space-2);
  margin-block-start: var(--space-6);
  /* The controls sit under the row's leading edge by default, where the
     eye returns after reading. A theme built on a centre line puts them on
     it instead — the same decision as `--card-align`, and for the same
     reason: one axis or none. */
  justify-content: var(--carousel-nav-align, flex-start);
}

.carousel__btn {
  width: 2.75rem;
  height: 2.75rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-full);
  background: var(--color-surface);
  color: var(--color-text);
  cursor: pointer;
  transition:
    border-color var(--motion-duration) var(--ease-out-expo),
    color var(--motion-duration) var(--ease-out-expo),
    opacity var(--motion-duration) var(--ease-out-expo);
}

.carousel__btn:hover:not(:disabled) {
  border-color: var(--color-primary);
  color: var(--color-primary);
}

/* Dimmed rather than hidden at the ends: a control that disappears takes
   its neighbour's position with it, and the pair would shuffle sideways
   every time the reader reached an edge. */
.carousel__btn:disabled {
  opacity: 0.35;
  cursor: default;
}

.carousel__btn .icon {
  width: 1.25rem;
  height: 1.25rem;
}

/* --------------------------------------------------------------------
   Quote dialog — the whole review, and the ones either side of it

   The card shows six lines; this is where the rest of them live. A native
   `<dialog>` rather than a hand-built overlay: focus trapping, Escape,
   inertness of the page behind and the backdrop all come with it, and
   every one of those is a thing this file would otherwise get subtly
   wrong.

   It pages, because a reader who opened one review to finish it is the
   reader most likely to want the next — and having got here they should
   not have to go back to the row to keep reading.
   -------------------------------------------------------------------- */
.quote-dialog {
  width: min(42rem, calc(100vw - var(--space-8)));
  padding: var(--space-8);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-lg);
  background: var(--color-surface);
  color: var(--color-text);

  /* A modal dialog is centred by the browser's own `margin: auto` against
     an `inset: 0` box. The reset in base.css zeroes every margin, which
     takes that with it — the dialog opened flush in the top-left corner
     and looked like a panel that had failed to position rather than a
     modal. Nothing in this file asked for that, which is what makes it
     worth stating: a reset can switch off behaviour nobody wrote.

     Centred across, pinned down the page. Vertical centring looks right
     until you page: reviews are 290px and 514px tall, so the box re-centred
     on every step and its top edge moved by up to 112px — the reader's eye
     is on the first line, and the first line kept walking away. Anchored,
     only the foot moves, and the words you are reading stay where you left
     them. */
  margin-inline: auto;
  margin-block: 8svh auto;

  /* A review with no ceiling on it is the reason this dialog exists, so
     the long ones have to be able to run past the screen and scroll
     rather than push their own foot out of reach. */
  max-height: min(80svh, 40rem);
  overflow-y: auto;
  /* The close button is pinned to this box, so it must be the containing
     block for it. */
  position: fixed;
}

/* ---- Opening and closing ------------------------------------------
   A dialog goes from `display: none` to shown, and a transition has
   nothing to run from across that — which is why it snapped in and out.
   Three pieces make it move, and all three are needed:

   `allow-discrete` lets `display` and `overlay` take part in a transition
   instead of switching instantly; without it the box would vanish on
   `close()` before its fade had a frame. `@starting-style` supplies the
   state to animate *from* on the way in, since the element has no previous
   computed style to interpolate against. And the fade has to live on
   `[open]` rather than on a class, so the browser's own open and close
   drive it and nothing here has to time a removal.

   Unsupported anywhere it is unsupported: the dialog simply appears, which
   is exactly what it did before. Nothing is lost, so nothing needs a
   fallback. */
.quote-dialog,
.quote-dialog::backdrop {
  opacity: 0;
  transition:
    opacity var(--motion-duration) var(--ease-out-expo),
    translate var(--motion-duration) var(--ease-out-expo),
    overlay var(--motion-duration) allow-discrete,
    display var(--motion-duration) allow-discrete;
}

.quote-dialog {
  /* The same short rise every block on the site arrives with. */
  translate: 0 var(--reveal-distance);
}

.quote-dialog[open],
.quote-dialog[open]::backdrop {
  opacity: 1;
}

.quote-dialog[open] {
  translate: none;
}

@starting-style {
  .quote-dialog[open],
  .quote-dialog[open]::backdrop {
    opacity: 0;
  }

  .quote-dialog[open] {
    translate: 0 var(--reveal-distance);
  }
}

.quote-dialog::backdrop {
  background: color-mix(in srgb, var(--color-surface-inverse) 55%, transparent);
}

/* Quote and author move together — they are one review, and changing the
   words while the name sat still would read as two things happening at
   different times.

   A reveal, not a slide. The lightbox throws a photograph out sideways
   because the picture *is* the thing being navigated; here the box stays
   put and only its contents change, so sending the whole card travelling
   made the dialog look like it was being dragged around. This is the same
   short rise and fade every block on the site arrives with — the page's
   own motion, applied to a swap. */
.quote-dialog__body {
  transition:
    translate var(--motion-duration-slow) var(--ease-out-expo),
    opacity var(--motion-duration) var(--ease-out-expo);
}

/* Leaving: fades where it stands. Travel on the way out would be the
   sideways drag again, in miniature. */
.quote-dialog__body.is-leaving {
  opacity: 0;
}

/* Arriving: from just below, exactly as `data-reveal="up"` does. */
.quote-dialog__body.is-entering {
  opacity: 0;
  translate: 0 var(--reveal-distance);
}

/* Placed at the start state without animating into it — see the two
   frames in scripts/testimonials.js. */
.quote-dialog__body.no-anim {
  transition: none;
}

.quote-dialog__quote {
  font-size: var(--text-lg);
  line-height: var(--leading-normal);
  /* The reason the dialog exists — never clamped here. */
  margin-block-end: var(--space-6);
}

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

/* Its own band at the top, not a button floating over the corner. Over the
   corner it sat on the first lines of a long review — which is the one
   case this dialog exists for — and it scrolled out of reach with the text
   the moment the review was long enough to need scrolling.

   Sticky, so it stays reachable at the bottom of a long one. The dialog
   scrolls, so the bar carries the surface colour with it or the words pass
   visibly underneath. Its inline padding matches the dialog's own, and its
   negative top margin pulls it back up into the dialog's padding so the
   band costs no height.

   It carries the paging as well as the close, which is why it is
   `space-between` rather than `flex-end`. The arrows used to sit in a
   foot below the review, where their position was a function of how much
   the reviewer wrote: page from a two-line quote to a ten-line one and
   the button you just pressed had moved down the screen. Here they are
   fixed — sticky at the top, in front of the text instead of after it —
   and pressing "next" five times means pressing the same place five
   times. */
.quote-dialog__bar {
  position: sticky;
  inset-block-start: calc(var(--space-8) * -1);
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  margin-block-start: calc(var(--space-8) * -1);
  margin-inline: calc(var(--space-8) * -1);
  padding: var(--space-3) var(--space-3) var(--space-2);
  background: var(--color-surface);
}

/* Arrows and counter as one object: the number says what the arrows are
   doing, so it travels with them rather than sitting across the bar. */
.quote-dialog__paging {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

/* `.carousel__nav` is written for a row of cards, where it opens a gap
   above itself and answers the theme's centre-line token. Neither applies
   inside a bar that is already positioned. */
.quote-dialog__nav {
  margin-block-start: 0;
  justify-content: flex-start;
}

.quote-dialog__count {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* --------------------------------------------------------------------
   Section header — eyebrow + display heading + lead.
   The recurring opening of most blocks.
   -------------------------------------------------------------------- */
/* The gap under a section's heading. It was 48px, which floated the
   heading away from the thing it introduces — the two read as separate
   items rather than as a label and its content, which is the whole job of
   a section header. Now 32px, two steps down.

   Every value tried was a step already in the scale: 48 → 40 → 32, never a
   number invented in between. A spacing scale earns its keep by being the
   only set of numbers in play (PRINCIPLES §5), and "a bit less" is exactly
   the request that quietly introduces a 44 nobody can account for later.

   Shared on purpose, and it shows up everywhere a block opens with a
   heading — services, references, testimonials, blog, FAQ, contact, the
   listings. The relationship between a heading and its content is one
   decision, not a per-block preference.

   One exception exists and it is not this rule's doing: the About block
   carries an inline `--space-6` on its own instance, so it keeps its
   tighter 24px and does not follow this. */
.section-header {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-4);
  max-width: 44rem;
  margin-block-end: var(--space-8);
}

.section-header--center {
  align-items: center;
  text-align: center;
  margin-inline: auto;
}

/* A centred header can open with a rule of light — the thin glowing
   hairline the reference language hangs above its section titles. Gated
   on `display`, so themes that never ask for it pay nothing: not even
   the flex gap, which a zero-height pseudo would still claim. */
.section-header--center::before {
  content: "";
  display: var(--heading-rule-display, none);
  height: 2px;
  width: min(11rem, 60%);
  border-radius: var(--radius-full);
  background: var(--heading-rule, none);
  box-shadow: var(--heading-rule-glow, none);
}

/* Headings can take a gradient, per theme. Two tokens because the effect
   is two declarations that only work as a pair: the gradient is painted as
   a background clipped to the glyphs, and the glyphs themselves have to go
   transparent for it to show. The defaults dismantle both — `none` paints
   nothing and `currentColor` is the colour the heading already had — so a
   theme that says nothing gets the page it always had.

   `.reveal-line > span` is on the list because of how the hero title is
   built: the glyphs live in the innermost spans, and a background clipped
   to text on the parent paints nothing for a child's glyphs. Without that
   selector a gradient theme's hero headline is simply invisible — the
   parent has the gradient and no text, the spans have the text and
   transparent ink. Each line restarts the gradient, so keep the sweep
   mostly horizontal and it reads as intent rather than as a seam.

   (`currentColor` as the fallback, not `inherit`: CSS-wide keywords are
   invalid inside `var()`, and for `color` the two do the same job.) */
.display,
.section-header h2,
.reveal-line > span {
  background-image: var(--gradient-heading, none);
  -webkit-background-clip: text;
  background-clip: text;
  color: var(--heading-ink, currentColor);
}

/* A heading whose glyphs live in reveal lines must NOT paint its own
   gradient, and this is a real bug rather than a tidiness rule: the
   ancestor's background layer is clipped to the text of everything
   painted inside it, so it draws a second copy of the same words — and
   because that layer belongs to the heading, not to the line, the
   `overflow: hidden` on `.reveal-line` does not clip it. On load the
   animated copy then slides over a static one that should not exist.

   `:has(.reveal-line)` rather than a class pairing, because the condition
   is structural: the glyphs are delegated to the line spans, which paint
   the gradient themselves. Higher specificity than the rule above, so
   source order cannot undo it. */
.display:has(.reveal-line),
.section-header h2:has(.reveal-line) {
  background-image: none;
}

/* The brand slab keeps its own ink. A light-to-dim gradient is drawn for
   the page's dark or light background, and on the one surface that is
   neither — the solid brand colour — it lands unreadable. Same shape as
   the statistics' surface overrides in blocks.css: legibility outranks
   the effect, using a token every theme has already verified against
   exactly this surface. */
.surface-brand .display,
.surface-brand .section-header h2,
.surface-brand .reveal-line > span {
  background-image: none;
  color: var(--color-text-on-primary);
}

.section-header__title {
  letter-spacing: var(--tracking-tight);
}

/* --------------------------------------------------------------------
   Section — vertical rhythm owned by the block itself (PRINCIPLES §3)
   -------------------------------------------------------------------- */
.section {
  padding-block: var(--section-space);
}

/* A section arrived at by anchor skips most of its own opening rhythm.

   That rhythm exists to separate the block from the one above it — and
   when a menu item brings you here, there is no block above it on screen
   for it to separate from. It was pure empty page: measured at 1440x700,
   148px between the bottom of the bar and the heading, a fifth of the
   screen, and worse the shorter the screen gets.

   The maths is the point. The browser lands the target at
   `scroll-padding` plus this margin, and the section's content sits one
   `--section-space` below that — so pulling back by exactly that rhythm
   and adding a fixed gap cancels the clamp, and the result no longer
   depends on how wide the screen is. Measured after: 32px of clearance
   under the bar on a 1440 desktop, 38px on a 375 phone, where before it
   was 118px and grew with the viewport.

   (The two differ by six because the floating pill sits a little lower on
   a phone than its 64px would suggest. The rule is doing the same thing in
   both; the bar is not in the same place.)

   Negative on purpose, and only on sections that are targets — `[id]` is
   what makes one. It assumes the standard `.section` padding, so a
   `.section--slim` given an id would need its own value; none has one
   today, and the selector says which rule to look at when one does. */
.section[id] {
  scroll-margin-block-start: calc(var(--space-12) - var(--section-space));
}

/* A page that opens with a breadcrumb does not need the landing-page
   overture. `main` already opens the clearance the floating header needs;
   a full block rhythm on top of that left 131px of nothing above a 12px
   line of navigation — air that reads as deliberate above a hero and as a
   hole above a breadcrumb.

   The bottom stays full: the block below still needs its rhythm. Only the
   opening of a subpage is different, so only that is changed. */
.section--subpage {
  padding-block-start: var(--space-16);
}

/* Panel — an inset, rounded slab instead of a full-bleed stripe.
   Full-bleed colour bands make a page read like a stack of documents;
   inset panels make each block read as its own object with the page
   showing through around it. Use it for blocks that carry a background
   (alt / brand / inverse); leave base-surface blocks flat so the page
   still breathes between panels.
   Inset stays on the 4px grid at both sizes (PRINCIPLES §5). */
.section--panel {
  /* `--panel-inset` is the whole panel language in one optional token. The
     default keeps the inset slabs; a theme that sets it to 0 (with
     `--radius-panel: 0`) turns every panel into a full-bleed band — edge to
     edge, flush against its neighbours, which is a genuinely different page
     from the same skeleton. One token because the side inset, the gap
     where two panels meet and the gap against the footer are one measure:
     the page showing around a panel. Zero any of them alone and the others
     read as mistakes. */
  margin-inline: var(--panel-inset, var(--space-3));
  border-radius: var(--radius-panel);
  /* Deliberately NOT `overflow: hidden`. Content sits inside .container
     with padding, so it never reaches the rounded corners and there is
     nothing to clip — but clipping here would silently amputate any
     dropdown, tooltip or sticky element a block later contains. */
}

@media (min-width: 48rem) {
  .section--panel {
    margin-inline: var(--panel-inset, var(--space-6));
  }
}

/* Panels that meet but do not merge must not touch. Two different
   surfaces flush against each other read as a mistake — and once a panel
   carries a photo, a hard edge against the next one looks broken. The
   gap matches the side inset, so the page background reads as one even
   gutter around every panel rather than a margin on three sides. */
/* The breadcrumb strip is the second thing a panel can meet, and it needs
   the same answer: the trail is a line of text, so a slab starting flush
   under it reads as the trail's own background rather than as a block.
   Corpse chain for the same reason the handover above has one. */
.section--panel + .section--panel,
.block-crumbs:not(.pv-off) + .section--panel,
.block-crumbs:not(.pv-off) + .pv-off + .section--panel,
.block-crumbs:not(.pv-off) + .pv-off + .pv-off + .section--panel,
.block-crumbs:not(.pv-off) + .pv-off + .pv-off + .pv-off + .section--panel {
  margin-block-start: var(--panel-inset, var(--space-3));
}

@media (min-width: 48rem) {
  .section--panel + .section--panel,
  .block-crumbs:not(.pv-off) + .section--panel,
  .block-crumbs:not(.pv-off) + .pv-off + .section--panel,
  .block-crumbs:not(.pv-off) + .pv-off + .pv-off + .section--panel,
  .block-crumbs:not(.pv-off) + .pv-off + .pv-off + .pv-off + .section--panel {
    margin-block-start: var(--panel-inset, var(--space-6));
  }
}

/* A panel that ends the page needs its gutter against the footer as much
   as against another panel. Without it the inset slab runs straight into
   the footer and the inset reads as a mistake on three sides.

   Written as "no visible section after me" rather than `:last-child`, so
   hiding the final block in the preview hands the gutter to whatever now
   ends the page (same reasoning as the merge rules below). */
main > .section--panel:not(.pv-off):not(:has(~ .section:not(.pv-off))) {
  margin-block-end: var(--panel-inset, var(--space-3));
}

@media (min-width: 48rem) {
  main > .section--panel:not(.pv-off):not(:has(~ .section:not(.pv-off))) {
    margin-block-end: var(--panel-inset, var(--space-6));
  }
}

/* Two panels of the same surface stacked together are one slab, not two.
   Left alone they each round their own corners and the join pinches
   inward — a seam that looks like a rendering fault. Square only the
   touching corners; the outer ones stay round.

   `:not(.pv-off)` keeps this honest in the preview: a hidden neighbour
   must not square the corners of a panel that now ends the run. In
   production the class never exists, so the check costs nothing. */
.section--panel.surface-alt:not(.pv-off):has(+ .section--panel.surface-alt:not(.pv-off)),
.section--panel.surface-brand:not(.pv-off):has(+ .section--panel.surface-brand:not(.pv-off)),
.section--panel.surface-inverse:not(.pv-off):has(+ .section--panel.surface-inverse:not(.pv-off)) {
  border-end-start-radius: 0;
  border-end-end-radius: 0;
}

.section--panel.surface-alt:not(.pv-off) + .section--panel.surface-alt:not(.pv-off),
.section--panel.surface-brand:not(.pv-off) + .section--panel.surface-brand:not(.pv-off),
.section--panel.surface-inverse:not(.pv-off) + .section--panel.surface-inverse:not(.pv-off) {
  border-start-start-radius: 0;
  border-start-end-radius: 0;
  /* Merging means becoming one slab: close the gap the rule above opens
     for panels that only meet, and drop the doubled inner padding that
     would otherwise stack at an invisible join. */
  margin-block-start: 0;
  padding-block-start: 0;
}


/* Two flat blocks on the same surface stack their rhythms without anything
   between them to say so. The merge rule above states this for panels;
   nobody had stated it for blocks that carry no panel, because in the
   shipped composition a panel always sat between two of them. Turn the
   panels off — which the client is entitled to do, and which is how this
   was found — and every gap doubles: 142px of the first block's rhythm
   against 142px of the second's, measured at 283px of undifferentiated
   page.

   **Half, not closed up, and the number is not a new one.** The article's
   coda hit exactly this and answered it first: `.block-next` in
   `article.css` trims its top to `--section-space / 2` against the
   article's full rhythm below, having measured 237px of the same emptiness.
   Its reasoning generalises word for word — the gap is doing a real job,
   which is to say one block is over before the next one starts, so it
   wants trimming rather than deleting. Zeroing it was tried here and gives
   142px, the figure two *merged* panels get; but merged panels are one
   slab with a border around the pair saying so, and two flat blocks have
   nothing. They are neighbours, not one object.

   That the two rules agree is what keeps the article page unchanged: both
   land on the same value there, so the block that already solved this
   privately now simply matches the system. `.block-next` stays, because on
   the reference page a panel sits above it and this rule correctly does
   not fire.

   `--slim` stays out, in both positions. Its tighter rhythm is the whole of
   what it is — the partner strip is meant to sit close — and trimming the
   block after it as well put a heading 32px under a row of logos.

   The `.pv-off` chain is one question asked four times, once per number of
   hidden neighbours the preview may have left lying between two blocks. A
   disabled block is absent from the real page, so `+` is the truth there
   and the first clause is the only one that can ever match; in the preview
   the block stays in the DOM under `display: none`, where `+` looks
   straight at it and concludes the two are not adjacent. Four covers
   hiding every panel on the longest page we have; past that the gap stays
   doubled, which is visibly wrong rather than quietly wrong, and that is
   the right way round for an artefact of the preview. */
.section.surface-base:not(.section--panel, .section--slim, .pv-off)
  + .section.surface-base:not(.section--panel, .section--slim, .pv-off),
.section.surface-base:not(.section--panel, .section--slim, .pv-off)
  + .pv-off + .section.surface-base:not(.section--panel, .section--slim, .pv-off),
.section.surface-base:not(.section--panel, .section--slim, .pv-off)
  + .pv-off + .pv-off + .section.surface-base:not(.section--panel, .section--slim, .pv-off),
.section.surface-base:not(.section--panel, .section--slim, .pv-off)
  + .pv-off + .pv-off + .pv-off + .section.surface-base:not(.section--panel, .section--slim, .pv-off) {
  padding-block-start: calc(var(--section-space) / 2);
}


/* --------------------------------------------------------------------
   Button
   -------------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  /* Split like the tag's, and for the same reason: a label centred on its
     line box is not centred on its letters. The box reserves descent under
     the baseline whether the words use it or not, so the capitals ride
     high — 1.5px of it on the large button, which is what a reader
     actually sees. `--btn-label-shift` moves the WORD without moving the
     button: what is added on top comes off the bottom. In `em`, so one
     value serves every size (PRINCIPLES §6 allows optical nudges there). */
  padding-block:
    calc(var(--space-3) + var(--btn-label-shift, 0px))
    calc(var(--space-3) - var(--btn-label-shift, 0px));
  padding-inline: var(--space-6);
  border: var(--border-width) solid transparent;
  /* Buttons get their own radius slot because `--radius-md` is shared with
     things that cannot go round — the textarea, the testimonial card, code
     blocks. A pill-button theme sets this to `--radius-full` and everything
     else keeps its corners. The inputs deliberately do NOT follow: a pill
     textarea is broken, and a form where only the multiline field has
     corners looks assembled from two kits — so the whole form stays on
     `--radius-md` and the pill is the button's own voice. */
  border-radius: var(--radius-button, var(--radius-md));
  /* Buttons speak in whatever voice the theme assigns the role, not the
     body font by default. A theme that points `--font-button` at its
     display face turns every button into a small brand moment; one that
     leaves it on the body font keeps them plainly functional. Nav links
     are not `.btn`, so they stay in the body font regardless. */
  font-family: var(--font-button);
  font-weight: var(--font-weight-bold);
  font-size: var(--text-base);
  /* A label's leading is a typographic choice like any other, so the theme
     owns it — and there is a second, less obvious reason it cannot be a
     constant. Half-leading is split evenly above and below the glyphs, so
     the label only lands on the button's centre line when the line box is
     an EVEN number of pixels. At 16px, 1.2 gives 19.2 and the label sits
     0.6px high; 1.25 gives 20 and it is exact. At 20px the two swap places
     — 24 is clean, 25 is not — so no single value is right for every step
     of the scale, and the honest thing is to let each theme tune it for
     the size its buttons are actually read at. Measured, not eyeballed:
     the header CTA went from 13.4/14.6 to 14/14. */
  line-height: var(--btn-line-height, 1.2);
  text-decoration: none;
  cursor: pointer;
  /* One transition, because `transition` is a single property — a second
     `.btn { transition }` elsewhere would replace this wholesale, not add
     to it. Colour and border snap on the quick curve; the hover lift and
     its shadow ride the slower expo the cards and footer pills use, so
     every raised surface rises the same way. The hover/active target
     values live in motion.css. */
  transition:
    background-color var(--motion-duration) var(--motion-easing),
    border-color var(--motion-duration) var(--motion-easing),
    color var(--motion-duration) var(--motion-easing),
    box-shadow var(--motion-duration-slow) var(--ease-out-expo),
    translate var(--motion-duration-slow) var(--ease-out-expo);
}

/* An icon in a button tracks the label rather than the global 1.5rem, so
   it reads as part of the word and scales with the button — 1.25em is
   20px next to base text and grows with `.btn--lg`. */
.btn .icon {
  width: 1.25em;
  height: 1.25em;
}

/* The filled button, and every value in it is a slot with the old
   behaviour as its default — a theme that sets none of them gets the solid
   brand fill this has always been.

   **`--btn-surface` and `--btn-ink` exist because a filled button is not
   always a `--color-primary` surface.** The moment a theme makes the
   button translucent — glass with a brand tint over whatever the page is
   showing — the fill stops being the brand colour and the label stops
   being the ink that was contrast-checked against it. Those are two
   different questions from "what is the brand", and `--color-text-on-primary`
   still answers the original one for every badge and notice that really
   does sit on a solid primary fill. Reusing it here would have tied a
   button's legibility to a token four other components depend on.

   `--btn-filter` is the backdrop, and it is the whole of what makes glass
   glass: without it a translucent fill is just a weak colour. Same slot
   shape as `--card-filter` and `--glass-filter`, and like them it defaults
   to `none` so nothing pays for a filter it does not use. */
.btn--primary {
  background: var(--btn-surface, var(--color-primary));
  color: var(--btn-ink, var(--color-text-on-primary));
  /* Two optional layers a theme can switch on, both defaulting to what the
     button has always been:

     `--btn-sheen` is a background-image laid over the colour — a highlight
     that fades from the top, which is what makes a flat fill read as a lit
     surface. It is an image, so the hover's colour change happens *under*
     it and the sheen never has to be redeclared per state.

     `--shadow-button` is the button's resting glow. The hover/active
     shadows live in motion.css with everything else that answers the
     pointer — see the note there for why the fallback chain matters. An
     `inset` layer in here is also how a glass button draws its own edge,
     which is why no border slot was added: the button already has a
     transparent border for layout, and a hairline drawn by the shadow
     lands inside it without changing a single measurement. */
  background-image: var(--btn-sheen, none);
  box-shadow: var(--shadow-button, none);
  backdrop-filter: var(--btn-filter, none);
}

.btn--primary:hover {
  background-color: var(--btn-hover-surface, var(--color-primary-700));
  color: var(--btn-hover-ink, var(--btn-ink, var(--color-text-on-primary)));
  /* Shadow and lift are centralised on `.btn:hover` now, so the variant
     only owns its colour shift — `background-color`, not the `background`
     shorthand, or this state would wipe the sheen the rule above laid on.
     The ink chain falls back through the resting ink before the original
     token, so a theme that recolours the label once does not have to say
     it twice. */
}

.btn--secondary {
  background: transparent;
  border-color: var(--color-primary);
  color: var(--color-primary);
}

/* The hover is carried by the tint, not by a second ink. `primary-50` under
   `primary-700` was a light-page assumption twice over: on a dark theme the
   pair painted a near-white pad under a dark-gold word, and the word that
   was legible at rest (2.6:1 after the swap) stopped being legible exactly
   while the reader was pointing at it. The tint now mixes into the page's own
   surface and the label simply stays the brand colour — a hover that changes
   one thing rather than two, and the only version that survives a theme
   going dark. */
.btn--secondary:hover {
  background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
}

.btn--ghost {
  background: transparent;
  color: var(--color-primary);
}

/* The quietest button, and the one with the most room to answer a pointer:
   it has no resting surface to change, so a theme may light its edge
   instead. Three slots, all defaulting to the tint this has always used —
   the surface, the border the base leaves transparent for layout, and the
   label. A theme that wants a lit ring sets the border and the shadow slot
   in motion.css; one that wants nothing new keeps the tint and cannot tell
   these exist.

   The border is the point of the middle one. `.btn` already carries
   `var(--border-width) solid transparent`, so a ghost button is exactly
   the same size as a filled one and colouring that border on hover moves
   nothing — a ring that appeared by *adding* a border would shift the
   label by two pixels on the way in. */
.btn--ghost:hover {
  background: var(--btn-ghost-hover-surface,
    color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)));
  border-color: var(--btn-ghost-hover-border, transparent);
  color: var(--btn-ghost-hover-ink, var(--color-primary));
}

.btn--sm {
  padding-block:
    calc(var(--space-2) + var(--btn-label-shift, 0px))
    calc(var(--space-2) - var(--btn-label-shift, 0px));
  padding-inline: var(--space-4);
  font-size: var(--text-sm);
}

.btn--lg {
  padding-block:
    calc(var(--space-4) + var(--btn-label-shift, 0px))
    calc(var(--space-4) - var(--btn-label-shift, 0px));
  padding-inline: var(--space-8);
  font-size: var(--text-lg);
}

.btn:disabled,
.btn.is-disabled {
  opacity: 0.5;
  cursor: not-allowed;
  box-shadow: none;
}

/* Loading state — spinner replaces the label's leading space */
.btn.is-loading {
  position: relative;
  color: transparent;
  pointer-events: none;
}

.btn.is-loading::after {
  content: "";
  position: absolute;
  width: 1em;
  height: 1em;
  border: 2px solid var(--color-text-on-primary);
  border-block-start-color: transparent;
  border-radius: var(--radius-full);
  animation: btn-spin 0.7s linear infinite;
}

@keyframes btn-spin {
  to { transform: rotate(1turn); }
}

/* --------------------------------------------------------------------
   Links
   -------------------------------------------------------------------- */
.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-weight: var(--font-weight-bold);
  text-decoration: none;
}

.link-arrow::after {
  content: "→";
  transition: translate var(--motion-duration) var(--motion-easing);
}

.link-arrow:hover::after {
  translate: 4px 0;
}

/* --------------------------------------------------------------------
   Tag / label
   -------------------------------------------------------------------- */
.tag {
  display: inline-block;
  /* The vertical halves are split so a theme can move the WORD inside the
     badge without moving the badge: whatever is added on top comes off the
     bottom, so the height never changes. See `--label-ink-shift` below. */
  padding-block:
    calc(var(--space-1) + var(--label-ink-shift, 0px))
    calc(var(--space-1) - var(--label-ink-shift, 0px));
  padding-inline: var(--space-3);
  /* Four slots, all defaulting to the filled badge this has always been.

     A tag is the same kind of object the eyebrow turned out to be: a made
     thing with an edge, which is one voice among several rather than the
     only way to label something. On a light page a pale filled badge is
     quiet — it is a tint of the page. Invert the page and the identical
     token becomes the brightest element in the card, louder than the
     heading it sits above, because `primary-100` is a near-white in every
     ramp. The eyebrow met this exact problem and got `--eyebrow-*`; this is
     the same argument for the same reason, and it is a token set rather
     than a fix in one theme because any dark theme will need it.

     `--tag-edge` is an inset shadow rather than a border, for the reason
     `--media-edge` is: a transparent border still takes space, so adding
     one would move every badge on every site to buy a line one theme
     draws. */
  border-radius: var(--tag-radius, var(--radius-sm));
  background: var(--tag-surface, var(--color-primary-100));
  color: var(--tag-ink, var(--color-primary-800));
  box-shadow: inset 0 0 0 var(--border-width) var(--tag-edge, transparent);
  font-size: var(--text-xs);
  font-weight: var(--tag-weight, var(--font-weight-bold));
  letter-spacing: var(--tag-tracking, inherit);
  text-transform: var(--tag-transform, none);
  text-decoration: none;
  white-space: nowrap;
  /* A label in a box does not want prose leading. Unset it inherits the
     body's, which is written for paragraphs and — at this size — rarely
     lands on an even number of pixels: half-leading is split above and
     below the glyphs, so an odd fraction puts the word off the badge's
     centre line. At 12px a body leading of 1.7 gives 20.4 and the text
     sits 0.7px high; 1.5 gives 18 and it is level.

     Third time this arithmetic has come up — see `--btn-line-height` and
     `--nav-line-height`. They stay separate tokens rather than one,
     because the value that makes a box even depends on the font size the
     component is set at, and these three are set at three different ones.

     ⚠ And an even box is only half of it here, which is worth knowing
     before anyone chases the other half with line-height again. A badge
     holds UPPERCASE text: the ink has no descender, but the font still
     reserves descent below the baseline, so the word sits high by roughly
     (descent − 0) / 2 no matter how the leading is set. Measured in
     Industry at 12px: 4.5px of air above the ink against 9.46 below —
     2.5px high, five times the line-box error. `--label-ink-shift` is the
     optical correction for that, and it belongs to the theme because it
     is a property of the face, not of the component. Expressed in `em` so
     it scales with the label (PRINCIPLES §6 allows exactly this). */
  line-height: var(--leading-ui, inherit);
}

/* --------------------------------------------------------------------
   Notice — the component the feedback state tokens exist for.
   `.notice--success | --warning | --error | --info`
   -------------------------------------------------------------------- */
.notice {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-4);
  border-radius: var(--radius-md);
  border-inline-start: 4px solid var(--notice-line);
  background: var(--notice-soft);
  color: var(--notice-strong);
  font-size: var(--text-sm);
}

.notice .icon {
  flex-shrink: 0;
  color: var(--notice-line);
}

.notice--success { --notice-line: var(--color-success); --notice-soft: var(--color-success-soft); --notice-strong: var(--color-success-strong); }
.notice--warning { --notice-line: var(--color-warning); --notice-soft: var(--color-warning-soft); --notice-strong: var(--color-warning-strong); }
.notice--error   { --notice-line: var(--color-error);   --notice-soft: var(--color-error-soft);   --notice-strong: var(--color-error-strong); }
.notice--info    { --notice-line: var(--color-info);    --notice-soft: var(--color-info-soft);    --notice-strong: var(--color-info-strong); }

/* Solid badge — the only place `-on` is used, and the reason it exists:
   text on a solid state colour is where amber quietly fails. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-full);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  background: var(--badge-bg);
  color: var(--badge-on);
}

.badge--success { --badge-bg: var(--color-success); --badge-on: var(--color-success-on); }
.badge--warning { --badge-bg: var(--color-warning); --badge-on: var(--color-warning-on); }
.badge--error   { --badge-bg: var(--color-error);   --badge-on: var(--color-error-on); }
.badge--info    { --badge-bg: var(--color-info);    --badge-on: var(--color-info-on); }

/* --------------------------------------------------------------------
   Divider
   -------------------------------------------------------------------- */
.divider {
  border: 0;
  border-block-start: var(--border-width) solid var(--color-border);
}

/* --------------------------------------------------------------------
   Icon — slot for an SVG from the chosen theme set (ANALYSIS §7a)
   -------------------------------------------------------------------- */
.icon {
  display: inline-flex;
  width: 1.5rem;
  height: 1.5rem;
  flex-shrink: 0;
}

.icon svg {
  width: 100%;
  height: 100%;
}

.icon--lg {
  width: 2.5rem;
  height: 2.5rem;
}

/* --------------------------------------------------------------------
   Image frame — fixed aspect ratios + no-photo fallback
   -------------------------------------------------------------------- */
.img-frame {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--color-surface-alt);
}

/* `--media-edge`: a hairline round any picture, for the theme that needs one.

   A photograph on a light page cuts its own edge — there is nowhere for it
   to go. On a dark one it does not: a dim interior shot against a black
   page has no boundary at all on two or three sides, so the picture stops
   being an object and becomes a lighter patch of page. Which sides depends
   on the photograph, so it cannot be fixed by cropping or by choosing
   better pictures.

   Default `transparent`, so every existing theme renders exactly as it did.

   **A pseudo-element, and not a `border`.** A transparent border still
   occupies space, so putting one here would move every image on every site
   by a pixel a side to buy a line that only one theme draws. An `outline`
   with a negative offset would do it without the layout cost, but it is the
   focus ring's property and the gallery item is a button — a resting
   outline there is a fight with `:focus-visible` waiting to happen. A
   pseudo paints over the picture, follows the corner radius by inheriting
   it, and costs nothing when the colour is transparent.

   `.embed` is in the list because a video poster is a photograph and has
   the same problem — a dark thumbnail against a dark page has no edge. The
   map is excluded: it carries a real border already, for the opposite
   reason (it is pale and lands on a pale panel), and two lines would read
   as a mistake. */
/* Not inside a card, and this is the same argument as excluding the map.

   A card already draws its own boundary — a border, or a surface that steps
   away from the page. The hairline exists to tell a reader where a picture
   stops, and inside a card the card has already said it: a second line a few
   pixels in from the first reads as a mistake rather than as an edge. It is
   most obvious on a listing, where nine cards each show the doubling nine
   times.

   Written as the token rather than as `box-shadow: none` on a longer
   selector, so it stays a decision a theme can still reach: a card variant
   that genuinely wants an inner edge sets `--media-edge` back on itself. */
.card,
.next-item {
  --media-edge: transparent;
}

.img-frame::after,
.gallery__item::after,
.embed:not(.embed--map)::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 var(--border-width) var(--media-edge, transparent);
  pointer-events: none;
  /* Above the poster and the facade, below nothing that takes a click —
     the facade is the button, and this must not eat its pointer events. */
  z-index: 1;
}

/* The gallery item is a `<button>` and had no positioning of its own; the
   hairline above needs somewhere to anchor. */
.gallery__item {
  position: relative;
}

.img-frame--16x9 { aspect-ratio: 16 / 9; }
.img-frame--4x3  { aspect-ratio: 4 / 3; }
.img-frame--3x4  { aspect-ratio: 3 / 4; }
.img-frame--1x1  { aspect-ratio: 1 / 1; }

.img-frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Fallback when no photo is provided (PRINCIPLES §8).
   A quiet tinted surface carrying the client's mark — it reads as
   "photo pending", not as a broken image, and it stays on-brand because
   the mark and the tint both come from the theme. Every block that
   shows a photo must survive not having one: the admin will be filled
   in over time, and half-filled must never look broken. */
/* The tint is mixed into the frame's own surface rather than taken from the
   ramp. `--color-primary-100` is a near-white by construction, so on a dark
   theme the "quiet tinted surface" above became the brightest rectangle on
   the page — and this is the state a client sees most, because every photo
   starts missing. Mixed against `--color-surface-alt` it stays a quiet tint
   whichever way the theme goes, and on a light theme it lands within a hair
   of where it was. */
.img-frame--empty {
  display: flex;
  align-items: center;
  justify-content: center;
  background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-alt));
  color: color-mix(in srgb, var(--color-primary) 55%, var(--color-surface-alt));
}

.img-frame--empty .icon {
  /* Scales with the frame rather than sitting at a fixed size, so it
     looks deliberate in a card thumbnail and in a hero alike. */
  width: clamp(2.5rem, 22%, 6rem);
  height: auto;
  aspect-ratio: 1;
}

/* --------------------------------------------------------------------
   Form fields
   -------------------------------------------------------------------- */
.field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.field__label {
  font-size: var(--text-sm);
  font-weight: var(--font-weight-bold);
}

.field__input {
  padding: var(--space-3) var(--space-4);
  border: var(--border-width) solid var(--color-border);
  /* Same radius as .btn — inputs, selects and buttons are all controls
     of the same size sitting next to each other in a form. Giving them
     different corners makes the form look assembled from two kits. */
  border-radius: var(--radius-md);
  background: var(--color-surface);
  /* Stated, not inherited: the background above is always the page
     surface, so the ink must be the surface's ink no matter where the
     field sits. Found on a violet hero panel — the field inherited the
     panel's white and typed white on white. */
  color: var(--color-text);
  /* The button's leading, not the body's. A single-line control was
     inheriting the paragraph 1.65 while the button beside it ran 1.2 —
     same font, same padding, and the input stood 7.6px taller for a
     line-height written for running text it will never contain. Sharing
     the button's token makes an input and a button in one row the same
     height by construction, in every theme; the multi-line exception is
     right below. */
  line-height: var(--btn-line-height, 1.2);
  transition: border-color var(--motion-duration) var(--motion-easing);
}

.field__input:focus-visible {
  outline: none;
  border-color: var(--color-primary);
  /* The whole shadow is the token, not just its colour: a ring is right on
     a light page, and a theme whose language is light wants a ring AND a
     glow, which is a different shape rather than a different value. The
     default is the ring this always drew — and note what that default is
     made of: `primary-100`, a near-white in every ramp by construction. On
     a dark page it halos the focused field in cream, which is the sixth
     time this session that the ramp's light end has been used as a tint
     and only worked because the page happened to be white. */
  box-shadow: var(--field-focus-ring, 0 0 0 2px var(--color-primary-100));
}

textarea.field__input {
  min-height: 6rem;
  resize: vertical;
  /* The one field that IS running text — the paragraph leading comes
     back. */
  line-height: var(--leading-normal);
}

/* --------------------------------------------------------------------
   Select — custom listbox (scripts/select.js)
   The native <select> stays in the DOM: it submits the form and is the
   whole control when JS is unavailable. Only the popup is ours, because
   the native one cannot be styled in any browser we support.
   -------------------------------------------------------------------- */
.select {
  position: relative;
}

/* The wrapper only exists once JS has run, so its presence is the signal
   that the native control can step aside. It stays in the DOM and still
   submits with the form. Without JS there is no wrapper at all and the
   native select renders as a normal field. */
.select .select__native {
  display: none;
}

.select__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  width: 100%;
  text-align: start;
  cursor: pointer;
  background: var(--color-surface);
  color: var(--color-text);
}

.select__value {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.select__chevron {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  color: var(--color-text-muted);
  /* Drawn shape, not a glyph — see BACKLOG "never rotate a text glyph" */
  transition: transform var(--motion-duration-slow) var(--motion-easing);
}

.select.is-open .select__chevron {
  transform: rotate(180deg);
  color: var(--color-primary);
}

.select.is-open .select__trigger {
  border-color: var(--color-primary);
}

.select__list {
  position: absolute;
  inset-inline: 0;
  top: calc(100% + var(--space-1));
  z-index: var(--z-overlay);
  margin: 0;
  padding: var(--space-1);
  list-style: none;
  max-height: 16rem;
  overflow-y: auto;
  background: var(--color-surface);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);

  visibility: hidden;
  opacity: 0;
  translate: 0 -4px;
  transition:
    opacity var(--motion-duration) var(--motion-easing),
    translate var(--motion-duration) var(--motion-easing),
    visibility var(--motion-duration);
}

/* Flip above the field when there is no room below */
.select--up .select__list {
  top: auto;
  bottom: calc(100% + var(--space-1));
  translate: 0 4px;
}

.select.is-open .select__list {
  visibility: visible;
  opacity: 1;
  translate: 0 0;
}

.select__option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  min-height: 44px;          /* comfortable touch target, on the 4px grid */
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background-color var(--motion-duration) var(--motion-easing);
}

/* Highlight is driven solely by `.is-active`, never by `:hover`.
   select.js syncs the pointer to it on mousemove, so exactly one row is
   ever lit — whether you arrived by mouse or by keyboard. */
/* A brand tint mixed into the list's own surface rather than the ramp's
   lightest step. `--color-primary-50` is a near-white by construction, so on
   a dark theme it painted a cream row under the list's light text — the one
   row you are pointing at became the one row you could not read. Mixing
   against `--color-surface` follows the page in whichever direction the
   theme goes, and on a light theme it lands within a hair of where it was. */
.select__option.is-active {
  background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
}

.select__option[aria-selected="true"] {
  font-weight: var(--font-weight-bold);
  /* The brand, not the ramp's 800. This ink sits on the list surface, not on
     a 100-tint chip, and 800 is chosen to read on the chip — on a dark theme
     it is a dark ink on a dark row. `--color-primary` is the one colour every
     theme has already had to make legible against its own page. */
  color: var(--color-primary);
}

/* A disabled option is shown the way a native select shows one — there,
   greyed, and not offering itself. Hiding it would make the list look
   like it had lost its first row. select.js skips it in every kind of
   navigation, so nothing here has to defend against a click. */
.select__option--disabled {
  color: var(--color-text-muted);
  cursor: default;
}

/* Nothing chosen yet: the trigger is showing a prompt, not an answer, and
   is toned to say so. `select--placeholder` is set whenever the value is
   empty, so it clears itself the moment a real option is picked. */
.select--placeholder .select__value {
  color: var(--color-text-muted);
}

.select__check {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  color: var(--color-primary);
  opacity: 0;
}

.select__option[aria-selected="true"] .select__check {
  opacity: 1;
}

.field__hint {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

.field__message {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
}

.field--error .field__input {
  border-color: var(--color-error);
}

.field--error .field__message {
  color: var(--color-error);
}

/* The two controls that are not a `.field__input`: the consent box draws
   its own square, and a file field's visible control is the drop zone. An
   error has to reach them too, or the message appears under a field that
   looks perfectly happy.

   `.field--checkbox` is named twice on purpose. The box gets its resting
   border from `.field--checkbox input[type="checkbox"]`, which is written
   further down this file at exactly the same specificity — so an error
   selector that only says `.field--error` loses on source order and the
   square stays calm while everything around it goes red. */
.field--error.field--checkbox input[type="checkbox"],
.field--error .field__drop {
  border-color: var(--color-error);
}

.field--success .field__input {
  border-color: var(--color-success);
}

.field--success .field__message {
  color: var(--color-success);
}

/* --------------------------------------------------------------------
   Embeds — YouTube, Spotify, a map. Click to load; see scripts/embed.js
   for why nothing third-party loads before the reader asks for it.

   Core, not article: the contact page's map is the same component with a
   different source, and the moment two kinds of page needed it, a file
   only articles load was the wrong home for it.
   -------------------------------------------------------------------- */
.embed {
  position: relative;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--color-surface-alt);
}

.embed--video { aspect-ratio: 16 / 9; }
.embed--audio { min-height: 152px; }
.embed--map   { aspect-ratio: 4 / 3; }

.embed iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.embed__facade {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  padding: var(--space-6);
  border: 0;
  width: 100%;
  background: none;
  color: var(--color-text);
  cursor: pointer;
  text-align: center;
}

.embed__poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.embed__scrim {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--color-surface-inverse) 55%, transparent);
}

/* Named, not `> *`: a blanket child rule has the same specificity as
   `.embed__poster` and wins by coming later, which knocks the poster and
   scrim out of their overlay and back into the flex flow. */
.embed__facade > .embed__play,
.embed__facade > .embed__label,
.embed__facade > .embed__note {
  position: relative;
  z-index: 1;
}

.embed--video .embed__facade {
  color: var(--color-text-on-inverse);
}

.embed__play {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  height: 64px;
  border-radius: var(--radius-full);
  background: var(--color-primary);
  color: var(--color-text-on-primary);
  box-shadow: var(--shadow-lg);
  transition: scale var(--motion-duration-slow) var(--ease-out-expo);
}

.embed__facade:hover .embed__play {
  scale: 1.08;
}

.embed__label {
  font-weight: var(--font-weight-bold);
}

.embed__note {
  font-size: var(--text-xs);
  opacity: 0.85;
  max-width: 32rem;
}

/* Bordered, unlike the other two. A video sits on a dark poster and cuts
   its own edge; a map is pale green and beige and lands on a panel that is
   also pale, so without a line the tile has no edge at all. */
.embed--map {
  border: var(--border-width) solid var(--color-border);
}

/* A map facade has no poster to sit on, so it leans on a brand tint instead.
   The tint is mixed into the panel's own surface rather than taken from the
   ramp's lightest step: `--color-primary-50` is a near-white in every theme,
   which is "a hair off the surface" on a light one and a lit panel on a dark
   one. Mixed, it is the brand's own colour at the page's own lightness —
   still never a grey hole. */
.embed--map {
  background:
    linear-gradient(
      135deg,
      color-mix(in srgb, var(--color-primary) 10%, var(--color-surface-alt)) 0%,
      var(--color-surface-alt) 100%
    );
}

/* --------------------------------------------------------------------
   File upload — covers the builder's Image / File / Files types.
   The native input keeps the picker, validation and form value; the
   label is the visible control, so no behaviour is reimplemented.
   -------------------------------------------------------------------- */
.field--file input[type="file"] {
  /* Not display:none — that removes it from the tab order and the
     keyboard could never reach the picker. */
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.field__drop {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-6);
  border: var(--border-width) dashed var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface);
  cursor: pointer;
  transition:
    border-color var(--motion-duration) var(--motion-easing),
    background-color var(--motion-duration) var(--motion-easing);
}

/* Brand tint mixed into the page's own surface, not the ramp's lightest
   step — `primary-50` is a near-white by construction and lights up a
   whole drop zone on a dark theme. Same substitution as the empty image
   frame, the map facade and the listbox row. */
.field__drop:hover,
.field--file input[type="file"]:focus-visible + .field__drop {
  border-color: var(--color-primary);
  background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}

/* Dragging a file over the zone */
.field__drop.is-over {
  border-color: var(--color-primary);
  border-style: solid;
  background: color-mix(in srgb, var(--color-primary) 14%, var(--color-surface));
}

.field__drop .icon {
  flex-shrink: 0;
  color: var(--color-primary);
}

.field__drop-text {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  min-width: 0;
}

.field__drop-hint {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

/* Chosen files, listed back so the reader can see what will be sent */
.field__files {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-block-start: var(--space-3);
  padding: 0;
  list-style: none;
}

.field__files li {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-full);
  background: var(--color-surface-alt);
  font-size: var(--text-xs);
}

/* Checkbox row (incl. GDPR consent) */
/* The native control is restyled rather than replaced: it keeps its own
   keyboard behaviour, focus handling and form value, and needs no extra
   markup wrapped around it. */
.field--checkbox {
  flex-direction: row;
  align-items: flex-start;
  /* The consent row and the submit button are the form's closing pair, so
     they take one token between them: a form on a centre line ends on it.
     Unset, both stay where they were — flush with the fields above. */
  justify-content: var(--form-footer-align, flex-start);
  gap: var(--space-3);
  /* The consent row is a row, so its error message would line up as a
     third column next to the label. Wrapping lets it take the line under
     the box, where every other field's message is. */
  flex-wrap: wrap;
}

/* Wrapping has a second effect that has to be cancelled here. A flex line
   is filled using each item's *hypothetical* size — for a label that is
   its max-content, the whole sentence on one line — and an item that does
   not fit is moved to the next line rather than shrunk. So in anything
   narrow, the label dropped below the box and left the tick sitting alone
   on a line of its own; it showed up first in the preview panel's own
   settings, which borrows this component into a 320px column. Basing the
   label on 0 makes it always fit, and it then grows into whatever is left
   beside the box. */
.field--checkbox .field__label {
  flex: 1 1 0;
  min-width: 0;
}

.field--checkbox .field__message {
  flex-basis: 100%;
}

.field--checkbox input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  /* Centre the box on the label's FIRST line — not on the whole label,
     which may wrap to three lines of consent text. Derived from the
     tokens rather than eyeballed, so it stays centred if a theme
     changes the type scale or the leading:
       (first line box − box height) ÷ 2
     (PRINCIPLES §5 — computed from relative measures, not a magic px.) */
  margin-block-start: calc(
    (var(--text-sm) * var(--leading-normal) - 20px) / 2
  );
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  cursor: pointer;
  transition:
    background-color var(--motion-duration) var(--motion-easing),
    border-color var(--motion-duration) var(--motion-easing);
}

/* The whole row answers the pointer, not just the 20px box */
.field--checkbox:hover input[type="checkbox"]:not(:disabled) {
  border-color: var(--color-primary-400);
}

.field--checkbox input[type="checkbox"]:checked {
  background: var(--color-primary);
  border-color: var(--color-primary);
}

/* The tick is a masked shape tinted with the theme's on-primary colour,
   not a baked-in white SVG: a theme whose primary is pale needs a dark
   tick, and a hardcoded white one would disappear. */
.field--checkbox input[type="checkbox"]::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-text-on-primary);
  -webkit-mask: var(--tick) center / 12px no-repeat;
  mask: var(--tick) center / 12px no-repeat;
  --tick: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 5 5 9-10'/%3E%3C/svg%3E");
  transform: scale(0.4);
  opacity: 0;
  transition:
    transform var(--motion-duration) var(--ease-spring),
    opacity var(--motion-duration) var(--motion-easing);
}

.field--checkbox input[type="checkbox"]:checked::after {
  transform: scale(1);
  opacity: 1;
}

.field--checkbox input[type="checkbox"]:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.field--checkbox .field__label {
  font-weight: var(--font-weight-body);
  font-size: var(--text-sm);
  cursor: pointer;
}

/* --------------------------------------------------------------------
   Choice cards — a radio group a person can see

   Graphic radio buttons: each option is a card with an icon or a short
   claim, and the radio itself is a real input laid invisibly over the
   whole card — so the keyboard, the form bridge and validation all see
   an ordinary radio group while the eye sees tiles. `:has()` carries
   the state out to the card; the checked ring is an inset shadow so
   nothing moves when it appears.

   A fieldset, because a group of radios is one question and the legend
   is its label. The `.field` flex column does not apply — fieldsets
   render their own way in every browser — so the rhythm is stated in
   margins instead of inherited from the gap.
   -------------------------------------------------------------------- */
.field--choice {
  display: block;
  border: 0;
  padding: 0;
  margin: 0;
  min-width: 0;
}

.field--choice legend {
  padding: 0;
  margin-block-end: var(--space-2);
}

.choice-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: var(--space-3);
}

.choice-card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-1);
  padding: var(--space-4) var(--space-3);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface);
  /* Stated, not inherited — the same lesson the input learnt: the card
     is always the page surface, so its ink must be the surface's ink no
     matter what slab the group sits on. Found on the violet calculator
     panel, where the labels inherited white and vanished into the white
     cards. */
  color: var(--color-text);
  cursor: pointer;
  transition:
    border-color var(--motion-duration) var(--motion-easing),
    background-color var(--motion-duration) var(--motion-easing),
    box-shadow var(--motion-duration) var(--motion-easing);
}

/* The input covers the card so a press anywhere is a press on the radio;
   opacity rather than `visibility`, or it would stop being focusable. */
.choice-card input[type="radio"] {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.choice-card .icon {
  width: 1.75rem;
  height: 1.75rem;
  color: var(--color-primary);
}

.choice-card__label {
  font-size: var(--text-sm);
  font-weight: var(--font-weight-bold);
}

.choice-card__hint {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

/* The optional flag ("Náš tip") sits on the card's shoulder. Solid
   success colours — a recommendation, not a warning. */
.choice-card__badge {
  position: absolute;
  inset-block-start: calc(var(--space-3) * -1);
  inset-inline-end: var(--space-2);
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-full);
  background: var(--color-success);
  color: var(--color-success-on);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  line-height: var(--leading-ui, 1.5);
}

.choice-card:hover {
  border-color: var(--color-primary-400);
}

/* `--choice-accent` is the checked (and focus) colour — the primary by
   default, and the reason it is a slot is the violet calculator panel:
   a primary ring at the card's edge sits against the primary slab and
   the chosen card stops announcing itself. Same shape as
   `--range-accent`, for the same reason.

   The ring alone was not enough (Dissi, 2026-08-11: "důchodci to
   neuvidí") — so a chosen card also GROWS A TICK: a filled disc with
   the check cut into it, in the card's top-left corner (top-right
   belongs to the badge). A mark you can point at survives every failure
   a colour treatment has: low vision, a washed-out projector, a panel
   the ring's colour half-matches. The disc is `--choice-accent`, the
   check `--choice-accent-ink` defaulting to `--color-text-on-primary` —
   the measured ink for the default disc, and a theme that repaints the
   disc owns the new pairing, exactly as with the button slots. */
.choice-card:has(input:checked) {
  border-color: var(--choice-accent, var(--color-primary));
  box-shadow: inset 0 0 0 1px var(--choice-accent, var(--color-primary));
  background: color-mix(in srgb, var(--choice-accent, var(--color-primary)) 6%, var(--color-surface));
}

.choice-card::before {
  content: "";
  position: absolute;
  inset-block-start: var(--space-2);
  inset-inline-start: var(--space-2);
  width: var(--space-5);
  height: var(--space-5);
  border-radius: var(--radius-full);
  background: var(--choice-accent, var(--color-primary));
  opacity: 0;
  transform: scale(0.4);
  transition:
    transform var(--motion-duration) var(--ease-spring),
    opacity var(--motion-duration) var(--motion-easing);
}

.choice-card::after {
  content: "";
  position: absolute;
  inset-block-start: var(--space-2);
  inset-inline-start: var(--space-2);
  width: var(--space-5);
  height: var(--space-5);
  background: var(--choice-accent-ink, var(--color-text-on-primary));
  -webkit-mask: var(--tick) center / 12px no-repeat;
  mask: var(--tick) center / 12px no-repeat;
  --tick: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 5 5 9-10'/%3E%3C/svg%3E");
  opacity: 0;
  transform: scale(0.4);
  transition:
    transform var(--motion-duration) var(--ease-spring),
    opacity var(--motion-duration) var(--motion-easing);
}

.choice-card:has(input:checked)::before,
.choice-card:has(input:checked)::after {
  opacity: 1;
  transform: scale(1);
}

/* The same ring every focused control on the site wears, drawn on the
   card because the input over it is invisible. */
.choice-card:has(input:focus-visible) {
  outline: 2px solid var(--choice-accent, var(--color-primary));
  outline-offset: 2px;
}

/* --------------------------------------------------------------------
   Range — a slider with its value written out

   The track's filled share comes from `--range-fill`, which
   scripts/range.js keeps equal to the thumb's position; the same script
   writes the formatted value into the `.field__output` line underneath.
   Without JS the control still works natively — the fill just rests at
   the fallback and the output shows whatever the markup shipped.

   Two vendor blocks saying the same thing, deliberately not merged: a
   selector list with one unknown pseudo-element is dropped whole, so
   sharing rules between `::-webkit-*` and `::-moz-*` silently kills
   both. Firefox gets the fill for free from `::-moz-range-progress`;
   WebKit has no such part, hence the gradient.
   -------------------------------------------------------------------- */
.field__range {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  background: transparent;
  cursor: pointer;
  /* Claims the thumb's full height so the touch target is the control,
     not the 8px track. */
  height: var(--space-6);
}

/* `--range-accent` is the slider's active colour — fill and thumb ring
   together, since they are one statement about where the value stands.
   Defaults to the primary; a block that puts the slider on a brand slab
   points it at something the slab cannot swallow (vitarent: the accent
   yellow on the violet panel), the same move as `--btn-brand-surface`. */
.field__range::-webkit-slider-runnable-track {
  height: var(--space-2);
  border-radius: var(--radius-full);
  background: linear-gradient(
    to right,
    var(--range-accent, var(--color-primary)) 0 var(--range-fill, 50%),
    var(--color-border) var(--range-fill, 50%) 100%
  );
}

.field__range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: var(--space-6);
  height: var(--space-6);
  margin-block-start: calc((var(--space-2) - var(--space-6)) / 2);
  border-radius: var(--radius-full);
  background: var(--color-surface);
  border: 2px solid var(--range-accent, var(--color-primary));
  box-shadow: var(--shadow-sm);
}

.field__range::-moz-range-track {
  height: var(--space-2);
  border-radius: var(--radius-full);
  background: var(--color-border);
}

.field__range::-moz-range-progress {
  height: var(--space-2);
  border-radius: var(--radius-full);
  background: var(--range-accent, var(--color-primary));
}

.field__range::-moz-range-thumb {
  width: var(--space-6);
  height: var(--space-6);
  border-radius: var(--radius-full);
  background: var(--color-surface);
  border: 2px solid var(--range-accent, var(--color-primary));
  box-shadow: var(--shadow-sm);
}

/* The value, said in words under the control — the number is the whole
   point of dragging, so it speaks at body size, centred like the thumb
   it reports on. */
.field__output {
  text-align: center;
  font-weight: var(--font-weight-bold);
}

/* --------------------------------------------------------------------
   Newsletter form — one field, one button, one promise

   An address-collection form: the e-mail goes wherever the site's other
   forms go (the admin's form bridge), nothing else is asked. Built from
   the same `.field` anatomy as the contact form, so form.js validates it
   in the same voice and the error message has a place to land under the
   input. The control's label is `.visually-hidden` — visibly, the label
   is the heading the block puts above the whole form.

   Layout is a flex row that the field wins: the input grows, the button
   keeps its size, and on a narrow column the pair wraps into a stack
   without a breakpoint — the aside and the footer are different widths
   and neither wants its own rule. The note under the pair states the
   double-opt-in honestly; consent belongs to the confirmation e-mail,
   which is why there is no checkbox here.
   -------------------------------------------------------------------- */
.newsletter-form {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--space-2);
}

.newsletter-form .field {
  flex: 1 1 14rem;
}

.newsletter-form__note {
  flex-basis: 100%;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

/* The done state: the whole form steps aside and a receipt takes its
   place — the thank-you page's own gesture at field scale, same
   success-soft disc, same check. For a one-question form this is the
   honest shape of "it worked": there is nothing left to fill in, so
   showing the empty field again would be an invitation to do it twice.
   The text says what happens next, because a newsletter signup is not
   done at submit — the double opt-in is. */
.newsletter-form__done {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  flex-basis: 100%;
}

.newsletter-form__done-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--space-10);
  height: var(--space-10);
  border-radius: var(--radius-full);
  background: var(--color-success-soft);
  color: var(--color-success);
}

.newsletter-form__done-mark .icon {
  width: 1.25rem;
  height: 1.25rem;
}

.newsletter-form__done strong {
  display: block;
  margin-block-end: var(--space-1);
}

.newsletter-form__done p {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* --------------------------------------------------------------------
   Form status — the line a whole form answers in

   `[data-form-status]` is form-post.js's contract: the bridge writes
   its verdict there and stamps `data-state`. It shipped unstyled — the
   INCORE hand-over has carried "the status line is an unstyled <p>"
   since run #1 — and stayed that way until the newsletter needed a
   visible confirmation. Field-level messages answer per control; this
   is the voice for "the whole thing worked" / "the whole thing did
   not". Empty it costs no height, so a form carries the element
   whether or not anything has spoken yet.
   -------------------------------------------------------------------- */
[data-form-status] {
  flex-basis: 100%;
  font-size: var(--text-sm);
  font-weight: var(--font-weight-bold);
}

[data-form-status]:empty {
  display: none;
}

[data-form-status][data-state="ok"] {
  color: var(--color-success-strong);
}

[data-form-status][data-state="error"] {
  color: var(--color-error);
}

.surface-inverse .newsletter-form__note {
  color: color-mix(in srgb, var(--color-text-on-inverse) 72%, transparent);
}

/* --------------------------------------------------------------------
   Card — one component, content variants: service / reference / article
   -------------------------------------------------------------------- */
/* Appearance only — all card motion lives in styles/motion.css */
/* The card's chrome is a voice, not an anatomy. A bordered box on a light
   page (drga) and bare content floating in the dark (ingrade) are the same
   card: same data, same slots, same hover promise. The tokens default to
   the box; a theme that strips them keeps the rounded media (the
   border-radius + overflow still clip it) so a chromeless card reads as a
   floating picture with text, not as markup that lost its stylesheet. */
.card {
  display: flex;
  flex-direction: column;
  border: var(--border-width) solid var(--card-border, var(--color-border));
  border-radius: var(--radius-lg);
  background: var(--card-surface, var(--color-surface));
  /* Glass, when a theme asks for it. Safe here in a way it is not on the
     header pill: `backdrop-filter` makes an element the containing block
     for fixed descendants, and a card has none — the trap recorded in
     BACKLOG applies to the pill, not to this. Worth something only when
     there is light behind the card to blur; on a flat surface it is an
     expensive no-op, which is why it defaults to none. */
  -webkit-backdrop-filter: var(--card-filter, none);
  backdrop-filter: var(--card-filter, none);
  box-shadow: var(--card-shadow, none);
  overflow: hidden;
}

.card__media {
  border-radius: 0;   /* media edge-to-edge inside the card */
}

/* The thumbnail opens whatever the title opens — a picture in a card
   looks clickable, so it had better be.

   Out of the tab order and hidden from assistive tech on purpose: it
   duplicates the title link exactly. Exposed, it would add a second stop
   and a second announcement of the same destination — worse for a
   keyboard and a screen reader, better only for a mouse. */
.card__media-link {
  display: block;
  color: inherit;     /* the empty-frame mark keeps its own tint */
}

.card__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-6);
  flex-grow: 1;
  /* One token, two properties, different defaults — `center` is the only
     value a theme would set and it is valid for both, so an axial theme
     centres the icon, the wrapped lines and the footer link with one line.
     Unset resolves to stretch/start: exactly the card as it always was. */
  align-items: var(--card-align, stretch);
  text-align: var(--card-align, start);
}

.card__title {
  font-size: var(--text-lg);
}

.card__title a {
  color: inherit;
  text-decoration: none;
}

.card__title a:hover {
  color: var(--color-primary);
}

.card__meta {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.card__text {
  color: var(--color-text-muted);
}

.card__footer {
  margin-block-start: auto;
  padding-block-start: var(--space-3);
}

/* Card grid used by blocks (services, references, articles).

   How many cards there are is the editor's decision, never ours, and it
   changes over a site's life: a new client launches with one reference
   and has nine a year later. So the layout reads the count instead of
   being told it — quantity queries (`:has(> :nth-child(n):last-child)`)
   rather than a variant someone has to remember to switch (PRINCIPLES
   §9: logic lives in the data). Nothing in the markup changes.

   Flex rather than grid, for one reason that grid cannot do: a trailing
   row that does not fill centres itself. Five cards land 3 + 2 centred
   instead of 3 + 2 hanging off the left edge, and that single behaviour
   is what makes every awkward count look deliberate. */
.card-grid {
  --card-cols: 1;                            /* mobile first */
  display: flex;
  flex-wrap: wrap;
  /* An incomplete last row starts where every other row starts.

     Centring it was the old behaviour and it is the wrong instinct: five
     cards in a three-column grid put two in the middle of the last row,
     each one straddling the gutters above it, and a reader who has been
     scanning a left edge for four cards suddenly has no edge to scan. It
     reads as a layout accident rather than as "that is all of them".
     Dissi's call, and it applies to services exactly as it does to a
     listing — the same grid, so the same rule, in one place.

     The count-specific rules further down are unaffected: they change how
     many columns there ARE (four cards become two-by-two rather than
     three and an orphan), which is a better answer than alignment where
     it applies and no answer at all where it does not. */
  justify-content: flex-start;
  gap: var(--space-6);
}

.card-grid > * {
  /* The 0.02px is not superstition: at some container widths the three
     bases plus two gaps round up past 100% and the last card drops to
     its own row. Shaving a hundredth of a pixel is invisible and stops
     it. */
  flex: 0 1 calc(
    (100% - (var(--card-cols) - 1) * var(--space-6)) / var(--card-cols) - 0.02px
  );
  min-width: 0;                              /* long words must not widen a card */
}

@media (min-width: 40rem) {
  .card-grid { --card-cols: 2; }
}

@media (min-width: 64rem) {
  .card-grid { --card-cols: 3; }

  /* Fewer, larger cards. A blog index has to make a dozen headlines
     scannable, so three across is right; a reference index has to make
     the work look worth commissioning, and at three across the
     photographs get too small to do that. Same component, different
     density — not a second kind of card. */
  .card-grid--showcase { --card-cols: 2; }

  /* Two cards across three columns leaves a visible hole where a third
     should be — the block reads as unfinished rather than as short. */
  .card-grid:has(> :nth-child(2):last-child) { --card-cols: 2; }

  /* Four in a three-column grid is the ugliest count: three and an
     orphan. Two-by-two is the same cards saying "this is all of them". */
  .card-grid:has(> :nth-child(4):last-child) { --card-cols: 2; }
}

/* ---- One card ------------------------------------------------------
   The first answer was to cap it at 34rem and centre it, which stopped
   it becoming a banner but left it looking like two thirds of a row that
   failed to load. A lone card needs a different *shape*, not a smaller
   version of the three-across one — and since the grid already knows the
   count, the shape can come from CSS with nothing changed in the markup.

   Two cases, because cards come in two builds. One with a photograph
   turns on its side: picture beside text, which is the layout every
   "featured item" has, and reads as deliberate at any width. One without
   — a service card, icon and text — has no second column to make, so its
   shape comes from proportion instead: full width, content centred and
   held to a readable measure. */
.card-grid:has(> :nth-child(1):last-child) { --card-cols: 1; }

@media (min-width: 48rem) {
  .card-grid:has(> :nth-child(1):last-child) > .card:has(.card__media) {
    flex-direction: row;
    align-items: stretch;
  }

  .card-grid:has(> :nth-child(1):last-child) > .card:has(.card__media) > .card__media-link {
    flex: 0 0 46%;
    display: flex;      /* lets the frame stretch to the card's height */
  }

  /* The frame's fixed ratio is what makes a thumbnail predictable in a
     row of three; here it is the thing stopping the picture from filling
     its half, so it gives way to the card's own height. */
  .card-grid:has(> :nth-child(1):last-child) .card__media-link .img-frame {
    flex: 1;
    aspect-ratio: auto;
    min-height: 18rem;
  }

  .card-grid:has(> :nth-child(1):last-child) > .card > .card__body {
    justify-content: center;
    gap: var(--space-3);
    padding: var(--space-10);
  }

  .card-grid:has(> :nth-child(1):last-child) > .card:not(:has(.card__media)) > .card__body {
    align-items: center;
    text-align: center;
    max-width: 56ch;
    margin-inline: auto;
  }

  /* One card carries the weight three used to share. */
  .card-grid:has(> :nth-child(1):last-child) .card__title {
    font-size: var(--text-2xl);
  }

  .card-grid:has(> :nth-child(1):last-child) .card__footer {
    margin-block-start: var(--space-2);   /* `auto` would shove it to the floor */
  }
}

/* --------------------------------------------------------------------
   Header — logo + nav + burger (mobile first)
   -------------------------------------------------------------------- */
.site-header {
  position: sticky;
  top: 0;
  z-index: var(--z-header);
  border-block-end: var(--border-width) solid var(--color-border);
  background: var(--color-surface);
  transition:
    translate var(--motion-duration-slow) var(--ease-out-expo),
    box-shadow var(--motion-duration-slow) var(--ease-out-expo);
}

/* ---- The bar goes over a photo hero, not above it -------------------
   Sticky means in flow, so on a page that opens with the photo hero the
   bar pushed the picture down by its own height — and then the hero paid
   for the header twice: once in real space above it, and again in the
   allowance built into its own top padding, which exists for a header
   that overlaps. Measured at 1440x660 that was the difference between 48px
   of page showing under the hero and 17px of it missing off the bottom.

   Over the photograph instead, exactly as the floating pill already is.
   Both variants then cost the page the same, which is the point of them
   being variants: swapping one for the other is a change of appearance,
   never of how much room the page has (PRINCIPLES §4).

   Only on a page that opens with that hero. Everywhere else the bar keeps
   its flow space, because there is nothing there for it to sit on top of
   and content would simply slide underneath it.

   `inset-inline: 0` is not optional: a fixed element shrinks to its
   content, so without it the full-width bar stops being full-width the
   moment it stops being sticky. */
body:has(.block-hero--photo:not([hidden], .pv-off))
  .site-header:not(.site-header--floating) {
  position: fixed;
  inset-inline: 0;
}

/* Scrolling down hides the bar; any upward scroll brings it back
   (scripts/header.js). Reading gets the full viewport, navigation is
   always one small gesture away. */
.site-header.is-hidden {
  translate: 0 -100%;
}

/* Only lift off the page once it is actually overlapping content */
.site-header.is-stuck {
  box-shadow: var(--shadow-md);
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  min-height: var(--header-height);
}

.site-header__logo {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-heading);
  font-weight: var(--font-weight-heading);
  font-size: var(--text-lg);
  color: var(--color-text);
  text-decoration: none;
}


/* The burger carries both icons and swaps between them, rather than the
   script rewriting the sprite reference: a swapped `<use>` snaps, and
   nothing in this system snaps (PRINCIPLES §12). Drawn shapes, not a
   character turned on its side — see the gotchas in BACKLOG. */
/* Both icons share one grid cell, rather than one sitting in flow and the
   other absolutely positioned over it. Stretched to `inset`, the × ended
   up a different size from the burger and pivoted around a different
   centre, so the swap read as a jump rather than a turn. Same cell, same
   box, same origin. */
.site-header__burger {
  display: grid;
  place-items: center;
  padding: var(--space-2);
  border: 0;
  background: none;
  cursor: pointer;
  color: var(--color-text);
  /* No grey flash on tap — the icon turning is the feedback. */
  -webkit-tap-highlight-color: transparent;
}

.site-header__burger .icon {
  grid-area: 1 / 1;
  transition:
    opacity var(--motion-duration) var(--ease-out-expo),
    rotate var(--motion-duration-slow) var(--ease-out-expo);
}

.site-header__burger-close {
  opacity: 0;
  rotate: -90deg;
}

.site-header__burger[aria-expanded="true"] .site-header__burger-open {
  opacity: 0;
  rotate: 90deg;
}

.site-header__burger[aria-expanded="true"] .site-header__burger-close {
  opacity: 1;
  rotate: none;
}

.site-nav {
  display: none;
}

/* ---- Fullscreen menu (mobile) -------------------------------------
   A dropdown hanging under the bar competes with the page behind it and
   gives the links no room to be anything but a list. Full screen, the
   menu is the only thing on screen and the links can carry the weight
   the rest of the type does.

   The wash lives on the header rather than on this panel, because the
   panel is a descendant of the pill — a background here would paint over
   the pill's own and the bar would disappear into the colour. As a
   sibling of the pill it sits underneath, and the bar stays legible on
   top of it, which is what keeps the way out visible. */
.site-nav.is-open {
  display: flex;
  position: fixed;
  inset: 0;
  flex-direction: column;
  justify-content: center;
  /* The menu follows the page's own axis: a theme that centres its
     sections centres its navigation too, or the one screen with nothing
     else on it is the one screen that contradicts the rest. Unset is the
     flush-left list it always was. The CTA and the phone number below
     carry `align-self`, so they take the same token rather than being
     left behind at the margin. */
  align-items: var(--nav-open-align, stretch);
  text-align: var(--nav-open-text-align, start);
  /* Clears the bar at the top; the gutter matches the page's. */
  padding: 6rem var(--gutter) var(--gutter);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.site-nav ul {
  list-style: none;
  margin: 0;
  padding: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.site-nav.is-open ul {
  padding: 0;
  gap: var(--space-1);
  /* The same token one level down. The menu's own align-items centres
     the LIST as a box, but the items inside it still stretched, and a
     fit-content link inside a stretched item sits at the left edge of
     the widest line — a centred block of left-set text. Found the first
     time a theme set the token and looked at a screenshot rather than
     the computed values, which all said "center" and were all talking
     about a different box. */
  align-items: var(--nav-open-align, stretch);
}

/* Menu type, not list type. At full screen the links are the page. */
.site-nav.is-open ul a {
  font-family: var(--font-heading);
  font-weight: var(--font-weight-heading);
  font-size: var(--text-4xl);
  line-height: var(--leading-tight);
  padding-inline: 0;
  padding-block: var(--space-1);
  border-radius: 0;
  background: none;
  /* The current-item underline is drawn across the link's whole box, and
     a full-bleed rule under one word reads as a divider rather than as a
     mark. Shrink the link to its text and the underline goes with it. */
  width: fit-content;
}

/* Items arrive one after another, the same short travel and expo-out as
   every other reveal on the site (PRINCIPLES §12). `both` holds the
   start state through the delay, so nothing flashes into place first.
   The index comes from scripts/header.js — the item count is the data's
   to decide, not the stylesheet's. */
.site-nav.is-open li,
.site-nav.is-open .site-header__cta,
.site-nav.is-open .site-nav__contact {
  animation: nav-item var(--motion-duration-slow) var(--ease-out-expo) both;
  animation-delay: calc(var(--i, 0) * var(--reveal-stagger-group) / 2);
}

@keyframes nav-item {
  from { opacity: 0; translate: 0 var(--reveal-distance); }
  to   { opacity: 1; translate: none; }
}

@media (prefers-reduced-motion: reduce) {
  .site-nav.is-open li,
  .site-nav.is-open .site-header__cta,
  .site-nav.is-open .site-nav__contact {
    animation: none;
  }
}

/* The page behind is held still by `overscroll-behavior: contain` above,
   not by freezing the document. Freezing it removes the scrollbar, and
   every fixed element — the header pill included — jumps sideways by its
   width the moment the menu opens. The menu is a scroll container that is
   already at its boundary, so the gesture has nowhere to chain to. */

.site-nav ul a {
  display: block;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  color: var(--color-text);
  text-decoration: none;
  font-weight: var(--font-weight-bold);
  /* Unset, a link takes the body's leading, which is right for the
     fullscreen menu where the links are read as a list. In a bar they are
     not read as a list, and the body's leading is both too generous and —
     if it lands on a fraction of a pixel, as 1.7 does at 16px — enough to
     leave the label visibly off the bar's centre line. Same arithmetic as
     `--btn-line-height`: half-leading only divides evenly when the line
     box is an even number of pixels. */
  line-height: var(--nav-line-height, inherit);
}

/* Two values mark a menu item, and the difference is not decorative:
   `page` means this link points at the page you are on, `true` means it
   points at the section you are in — the blog listing while you read one of
   its articles, or a homepage block you have scrolled to. A screen reader
   should not announce "current page" for a link that navigates elsewhere.
   Both look identical; only the announcement differs.

   `:not([aria-current="false"])` because the attribute's own negative value
   is a real thing and must not light anything up. */
/* The two states shared one rule, which quietly said they mean the same
   thing. They do not: one is where you are, the other is where you could
   go. Split, with the hover falling back to the current-item colour so
   nothing changes for a theme that does not care about the difference. */
/* The pill behind a link exists only in the fullscreen menu — the desktop
   query below turns it off and lets the marker do the work. Mixed into the
   surface for the same reason as everywhere else: `primary-50` is a
   near-white, and in a dark theme's menu it would flash a cream pill
   behind the word you are pointing at.

   `--nav-open-link-surface` can drop it entirely. On a screen holding
   nothing but five words at display size, a rectangle behind one of them
   is a highlight borrowed from a bar that is not there — a theme may say
   the colour is enough. Unset, the pill stays. */
.site-nav ul a[aria-current]:not([aria-current="false"]) {
  color: var(--color-primary);
  background: var(--nav-open-link-surface,
    color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)));
}

.site-nav ul a:hover {
  color: var(--nav-link-hover-ink, var(--color-primary));
  background: var(--nav-open-link-surface,
    color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)));
}

/* Main CTA — last thing in the bar, first thing the eye lands on.
   In the fullscreen menu it sits below the links and aligns with them. */
.site-header__cta {
  margin-block-start: var(--space-8);
  align-self: var(--nav-open-align, flex-start);
}

/* The phone number closes the menu, quietly. Someone who opened the menu
   on a phone is often looking for exactly this, and making them find the
   contact block at the bottom of the page for it is a poor joke. Small
   on purpose: it is the fallback, not the offer — the CTA above it is
   what the client wants pressed. */
.site-nav__contact {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  align-self: var(--nav-open-align, flex-start);
  margin-block-start: var(--space-6);
  font-size: var(--text-base);
  font-weight: var(--font-weight-bold);
  color: var(--color-text-muted);
  text-decoration: none;
}

.site-nav__contact .icon {
  width: 1.25rem;
  height: 1.25rem;
  color: var(--color-primary);
}

.site-nav__contact:hover {
  color: var(--color-primary);
}

/* The bar keeps the burger until the full nav genuinely fits. Measured
   on the Drga header: logo 205 + links 393 + CTA 200 = 798px of content,
   against roughly `viewport - 108` of usable width, so it stops fitting
   below ~906px. At the old 48rem the CTA hung 141px outside the pill.

   Raised to 64rem rather than dropping the CTA on tablets, because
   hiding it would remove content and a variant may only change layout
   (PRINCIPLES §4). Nothing is lost this way — the fullscreen menu
   carries the links, the CTA and the phone number.

   The budget is about 800px of content. A client with a longer brand
   name or a sixth menu item will need this re-measured rather than
   assumed. */
@media (min-width: 64rem) {
  .site-header__burger { display: none; }

  .site-nav,
  .site-nav.is-open {
    display: flex;
    align-items: center;
    flex: 1;
    position: static;
    /* The fullscreen menu's own positioning has to be undone here, not
       just its looks — `inset: 0` on a static element is inert, but
       `overflow` and the animation are not. */
    inset: auto;
    overflow: visible;
    background: none;
    border: 0;
    box-shadow: none;
    padding: 0;
  }

  .site-nav.is-open li,
  .site-nav.is-open .site-header__cta,
  .site-nav.is-open .site-nav__contact,
  .site-nav.is-open ul a {
    animation: none;
    font-size: inherit;
    font-family: inherit;
  }

  /* The bar has the phone number nowhere near it, and no room for it —
     this is a mobile-menu affordance only. */
  .site-nav__contact {
    display: none;
  }

  /* The pill can hug its contents instead of spanning the container: a
     compact bar floating in the middle of the page rather than a rail
     across it. Desktop only, and that is not a detail — below the nav
     breakpoint the bar holds a logo and a burger, and a pill shrunk to
     those two reads as broken rather than as compact. Scoped to the
     floating variant for the same reason: variant B is a full-width bar
     by definition, and a fitted one would not be that variant any more.

     With the pill fitted there is no free space left in the row, so the
     nav's `flex: 1` and the links' auto margins quietly stop doing
     anything — the gap below is then the only thing standing between the
     logo and the first link. */
  .site-header--floating .site-header__inner {
    width: var(--header-pill-width, 100%);
    gap: var(--header-pill-gap, var(--space-4));
  }

  .site-nav ul {
    flex-direction: row;
    padding: 0;
    gap: var(--space-1);
    /* Auto margins on both sides centre the links in whatever space is
       left between the logo and the CTA. `auto 0` instead rides them to
       the right, against the CTA — which is a different reading of the
       bar: centred links are a menu, right-aligned links are an approach
       run to the one button that matters. */
    margin-inline: var(--header-nav-margin, auto);
  }

  /* On desktop the animated underline (motion.css) replaces the pill */
  .site-nav ul a:hover,
  .site-nav ul a[aria-current]:not([aria-current="false"]) {
    background: none;
  }

  .site-header__cta {
    margin: 0;
    align-self: auto;
    flex-shrink: 0;
  }
}

/* ---- The wash behind the fullscreen menu --------------------------
   On the header, not on the menu panel: the panel is a descendant of the
   pill, so a background there would paint over the pill's own and the
   bar would vanish into the colour with no visible way out. As a sibling
   of the pill this sits underneath it.

   Surface-alt rather than a new token: it is a role every theme already
   defines and already pairs with --color-text across half the blocks, so
   the contrast is verified by everything else on the page.

   The breakpoint MUST match `--bp-nav` (64rem): the fullscreen menu opens
   at every width below it, and the wash, the bar's stacking context and
   the variant-B offset all belong to that menu. This query was left at
   47.99rem when the nav moved from 48rem to 64rem, so on an iPad — 768 to
   1024px — the menu opened with no wash behind it and the links hung over
   the live page. A media query cannot read a custom property, so this is
   the one unavoidable copy of the number; it is 0.01rem below 64 for the
   same reason `header.js` subtracts 0.01px. When --bp-nav changes, change
   this too. */
@media (max-width: 63.99rem) {
  .site-header::before {
    content: "";
    position: fixed;
    inset: 0;
    /* The fullscreen wash can be glass too — the page dimly visible and
       blurred behind the open menu instead of flatly replaced. Separate
       colour token from the pill's, because the two carry different loads:
       the pill is a strip whose worst case is one band of content, the
       wash sits behind full-height navigation text and wants more opacity
       than the pill dares. Defaults keep the solid wash. */
    background: var(--glass-wash, var(--color-surface-alt));
    -webkit-backdrop-filter: var(--glass-filter, none);
    backdrop-filter: var(--glass-filter, none);
    opacity: 0;
    visibility: hidden;
    transition:
      opacity var(--motion-duration-slow) var(--ease-out-expo),
      visibility var(--motion-duration-slow);
  }

  .site-header.is-menu-open::before {
    opacity: 1;
    visibility: visible;
  }

  /* Variant B is a full-bleed opaque bar, not a pill floating over the
     page — so the wash starts below it rather than behind it. An
     element's own background paints under every positioned descendant,
     the wash included, so covering the bar would have meant covering the
     only surface it has. The floating variant keeps the wash edge to
     edge: its pill carries its own background and sits on top. */
  .site-header:not(.site-header--floating)::before {
    inset-block-start: var(--header-height);
  }

  /* The bar rides above both the wash and the panel it opened.

     The pill needs this as much as its contents do: the wash is a
     positioned element, so it paints above every unpositioned one no
     matter where it sits in the DOM — the pill's own background included.
     Without this the bar dissolved into the colour and only the logo and
     the × were left floating on it. */
  .site-header__inner {
    position: relative;
  }

  .site-header__logo,
  .site-header__burger {
    position: relative;
    z-index: var(--z-base);
  }
}

/* --------------------------------------------------------------------
   Header variant B — floating pill
   Sits over the page rather than banding across it. Fixed rather than
   sticky so it genuinely overlays; the page compensates with top
   padding via :has() below, which self-corrects when the variant is
   switched off in the preview panel.
   -------------------------------------------------------------------- */
.site-header--floating {
  position: fixed;
  top: var(--space-4);
  inset-inline: 0;
  border-block-end: 0;
  background: transparent;
  /* The pill is the container itself, and the floating variant replaces
     the container's padding with its own — so without this the bar ran
     flush to both screen edges while every line of text below it was
     inset by a gutter. Costs nothing on desktop, where the container's
     max-width already keeps the pill well inside this. */
  padding-inline: var(--gutter);
}

.site-header--floating.is-hidden {
  translate: 0 calc(-100% - var(--space-4));
}

.site-header--floating .site-header__inner {
  /* The CTA nests inside the pill, so it needs the same gap on top,
     bottom and right — otherwise it reads as pushed inwards. Height is
     driven by padding rather than min-height so those three gaps are
     literally the same value; only the leading edge keeps the wider
     inset the logo needs. */
  min-height: 0;
  padding: var(--space-2);
  padding-inline-start: var(--space-6);
  /* The pill's edge is its own token: on a glass bar the page's border
     colour is a drawn outline where the language wants a highlight, and
     the two want opposite weights. It brightens on hover — the bar
     answering the pointer as one object, before any individual link
     does. Both default to the page's border, so nothing moves untold. */
  border: var(--border-width) solid var(--header-pill-border, var(--color-border));
  border-radius: var(--radius-full);
  transition: border-color var(--motion-duration-slow) var(--motion-easing);
  /* The pill can be glass, per theme: a translucent surface with the page
     blurred through it. Both tokens default to the solid pill this always
     was.

     ⚠ For any theme that goes glass: the pill's text sits on *whatever
     scrolls under it*, so `--glass-surface` has to be measured against the
     lightest and darkest thing on the site, not against the page at rest.
     Translucency is a contrast promise about content that has not been
     written yet — which is why the alpha in a glass theme should err far
     above what the resting page needs. */
  background: var(--glass-surface, var(--color-surface));
  box-shadow: var(--shadow-lg);
  /* The blur lives on the ::after below, NOT here, and that is
     load-bearing: `backdrop-filter` makes an element the containing block
     for every `position: fixed` descendant — the same rule as `filter` —
     and the fullscreen mobile menu is a fixed descendant of this pill.
     With the filter here, "fullscreen" meant "the size of the pill": the
     menu rendered inside it, clipped to two overlapping links. A pseudo
     has no descendants, so it can carry the filter safely; `isolation`
     keeps its z-index: -1 from slipping behind the page. */
  position: relative;
  isolation: isolate;
}

.site-header--floating .site-header__inner::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  -webkit-backdrop-filter: var(--glass-filter, none);
  backdrop-filter: var(--glass-filter, none);
}

.site-header--floating .site-header__inner:hover {
  border-color: var(--header-pill-border-hover, var(--header-pill-border, var(--color-border)));
}

/* ---- Tablet: the logo takes the middle -----------------------------
   Between the phone and the desktop nav the bar is wide and holds only two
   things, so a left-aligned mark ends up stranded against one corner of a
   lot of empty pill. Centred, it reads as a masthead — which is what a
   header showing no navigation actually is.

   Phones keep it on the left. There the bar is barely wider than the logo,
   so centring buys nothing and costs the mark its alignment with
   everything else on the page.

   A three-column grid rather than absolute positioning: the outer columns
   are both `1fr`, so they stay equal to each other and the middle one is
   centred in the bar rather than in whatever is left over beside the
   burger.

   No magic number for the burger's width, and none needed. `1fr` is
   `minmax(auto, 1fr)`, so the last column cannot shrink below the burger's
   own min-content and the first column matches it. A long brand name is
   bounded symmetrically and can never slide under the button — it shortens
   the middle column instead, which is why the logo needs `min-width: 0` to
   be allowed to shrink at all.

   The pill's wider leading inset goes symmetric here, and has to. It
   exists so a logo sitting at the leading edge clears the rounded corner;
   with the logo in the middle it only drags the content box off centre —
   measured at 8px, which is exactly (24 − 8) / 2. The grid would have gone
   on centring the logo perfectly inside a box that was itself crooked,
   which is the kind of fault that gets blamed on the centring.

   Placed after the pill's own rule rather than given a heavier selector:
   both are (0,2,0) and this one only needs to come second. Specificity
   wars in this file have their own entry in BACKLOG.

   The upper bound is the hand-typed copy of `--bp-nav` (a media query
   cannot read a custom property), the third in this file after the wash
   and the nav itself. When the token moves, grep for 63.99rem. */
@media (min-width: 48rem) and (max-width: 63.99rem) {
  .site-header__inner {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
  }

  .site-header__logo {
    grid-column: 2;
    justify-self: center;
    min-width: 0;
  }

  .site-header__burger {
    grid-column: 3;
    justify-self: end;
  }

  .site-header--floating .site-header__inner {
    padding-inline-start: var(--space-2);
  }
}

.site-header--floating.is-stuck {
  box-shadow: none;
}

/* The one place a button leaves the shared radius. The floating header is
   itself a pill, and a 12px-cornered button inside a fully round bar reads
   as a mismatch rather than a system. Scoped to this variant only —
   variant A's CTA stays identical to every other button on the page. */
.site-header--floating .site-header__cta {
  border-radius: var(--radius-full);
}

/* The floating header is out of flow, so the page opens its own space
   for it — and stops doing so the moment the variant is hidden. */
body:has(.site-header--floating:not([hidden], .pv-off)) main {
  padding-block-start: var(--space-16);
}

/* The floating variant's dropdown is gone — both variants now open the
   same fullscreen menu, so there is nothing left here to special-case.
   The pill keeps its shadow while the menu is open; it is the one thing
   standing above the wash and it should look like it. */
@media (max-width: 47.99rem) {
  .site-header--floating.is-menu-open .site-header__inner {
    box-shadow: var(--shadow-lg);
  }
}

/* --------------------------------------------------------------------
   Testimonial
   -------------------------------------------------------------------- */
/* Every colour in the card is a slot with the quiet default it has
   always had. The first theme to fill them (vitarent) turns the card
   into a solid brand object — dark green, white ink — and that is a
   *surface* decision, so all five move together: a theme that recolours
   the card owns every ink that sits on it, measured (§11), because the
   defaults were measured against the alt surface and mean nothing on an
   arbitrary fill. */
.testimonial {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  padding: var(--space-6);
  border-radius: var(--radius-md);
  background: var(--testimonial-surface, var(--color-surface-alt));
  color: var(--testimonial-ink, currentColor);
}

/* Held to six lines, because the length of a review is the reviewer's
   business and the shape of the block is ours. One person writes fifty
   characters and the next writes five hundred; without a ceiling the card
   grows to fit the longest one and every other card in the row grows with
   it, so a single enthusiast decides how tall the block is.

   Lines rather than characters. A character count cannot know how wide the
   card is or how the words break, so it clips mid-word on one screen and
   leaves a ragged half-line on another; six lines is six lines at every
   width, in every theme, in a language that writes longer words than
   English. */
.testimonial__quote {
  font-size: var(--text-lg);
  line-height: var(--leading-normal);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 6;
  overflow: hidden;
}

/* Only where the clamp actually bit — scripts/testimonials.js adds this,
   so a quote that fits is never offered a way to read what is already
   there. Sits between the quote and the name, in the flow, so the author
   line stays where the card's foot is. */
.testimonial__more {
  align-self: flex-start;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  font: inherit;
  font-size: var(--text-sm);
  font-weight: var(--font-weight-bold);
  color: var(--testimonial-more-ink, var(--color-primary));
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* 700, which is what every other link hover in the system uses. 800 is an
   ink for the light chips and disappears into a dark card. */
.testimonial__more:hover {
  color: var(--testimonial-more-hover-ink, var(--color-primary-700));
}

/* Pushed to the foot of the card, and this is the whole answer to quotes
   of wildly different lengths. Grid stretches both cards to the taller
   one, so a "Perfektní práce." beside a five-sentence story already shared
   a box height — but the names sat wherever their own quote happened to
   end, 129px down one card and 393px down the other, with 288px of void
   under the short one. Two boxes the same size with their feet in
   different places is what reads as scattered.

   On a common foot line the pair reads as designed instead: the shorter
   quote simply has more air above its name, which is what a short quote
   is. Measured after: both at 393, both with the same 24px beneath.

   `auto` on the top margin rather than `justify-content: space-between` on
   the card, so the gap between quote and name stays the flex `gap`
   wherever there is no slack to distribute — a card whose content fills it
   should not have its parts pushed apart. */
.testimonial__author {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-block-start: auto;
}

.testimonial__avatar {
  width: 2.75rem;
  height: 2.75rem;
  border-radius: var(--radius-full);
  overflow: hidden;
  background: var(--testimonial-avatar-surface, var(--color-primary-100));
  /* 800, not 700, and the same pair the tag uses. 700 is the button's hover
     surface, so on a theme whose brand is light it has to stay light enough
     to carry dark text — which leaves it too light to be an ink on this
     chip. Initials measured 1.98 against it. The ramp's inks are 800/900. */
  color: var(--testimonial-avatar-ink, var(--color-primary-800));
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: var(--font-weight-bold);
  flex-shrink: 0;
}

.testimonial__name {
  font-weight: var(--font-weight-bold);
}

.testimonial__role {
  font-size: var(--text-sm);
  color: var(--testimonial-role-ink, var(--color-text-muted));
}

/* --------------------------------------------------------------------
   Accordion (FAQ) — native <details>, no JS
   -------------------------------------------------------------------- */
/* Two looks from one structure, and the difference is chrome rather than
   markup: a *list* of questions separated by rules (drga), or a *stack of
   panels* (ingrade). Every token below defaults to the list, so a theme
   that says nothing renders exactly what this always did.

   The trick that makes it one rule set rather than two: the item's side
   and top borders are 0-width by default, so they cost nothing and shift
   nothing, and the bottom border falls back through
   `--accordion-panel-border` → `--accordion-rule` → `--color-border`. A
   panel theme sets the panel border and gets all four sides at once; a
   list theme leaves it alone and gets the separator it always had.
   Setting `--accordion-rule: transparent` then removes the container's
   leading rule, which is the one piece of the list look that is not on
   the item itself. */
.accordion {
  display: flex;
  flex-direction: column;
  gap: var(--accordion-gap, 0);
  border-block-start: var(--border-width) solid var(--accordion-rule, var(--color-border));
}

.accordion__item {
  border-style: solid;
  border-color: var(--accordion-panel-border, transparent);
  border-width: var(--accordion-panel-width, 0);
  border-block-end-width: var(--border-width);
  border-block-end-color: var(--accordion-panel-border, var(--accordion-rule, var(--color-border)));
  border-radius: var(--accordion-radius, 0);
  background: var(--accordion-surface, transparent);
  box-shadow: var(--accordion-shadow, none);
  -webkit-backdrop-filter: var(--accordion-filter, none);
  backdrop-filter: var(--accordion-filter, none);
}

/* The inset is the other half of "panel": a rule-separated list is flush
   with the column, a panel holds its content away from its own edge.
   accordion.js needs no telling — it measures what the element contributes
   beyond its summary, so borders and padding are already in its numbers. */
.accordion__item summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  padding: var(--space-4) var(--accordion-inset, 0);
  font-weight: var(--font-weight-bold);
  cursor: pointer;
  list-style: none;
}

.accordion__item summary::-webkit-details-marker {
  display: none;
}

/* The marker is two drawn bars, not a "+" glyph. Rotating a text
   character re-rasterises it every frame — hinting and subpixel
   antialiasing shift with the angle, which is what made it crawl.
   Two gradient bars are plain rectangles: the browser can rasterise
   once and composite the turn. */
.accordion__item summary::after {
  content: "";
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  background:
    linear-gradient(currentColor, currentColor) center / 2px 16px no-repeat,
    linear-gradient(currentColor, currentColor) center / 16px 2px no-repeat;
  color: var(--color-primary);
  transform: rotate(0deg);
  transform-origin: center;
  transition: transform var(--motion-duration-slow) var(--motion-easing);
  will-change: transform;
}

.accordion__item[open] summary::after {
  transform: rotate(45deg);
}

/* While collapsing, `open` is still true until the animation ends —
   rotate the marker back straight away so it tracks the gesture. */
.accordion__item.is-collapsing summary::after {
  transform: rotate(0deg);
}

.accordion__content {
  padding: 0 var(--accordion-inset, 0) var(--space-4);
  color: var(--color-text-muted);
}

/* --------------------------------------------------------------------
   Pager — previous / next only

   Numbered pagination is gone, and not because a row of page numbers is
   a bad control: the admin cannot produce one. It hands a template the
   previous and next links and nothing else — no page count, no current
   page, no total. A numbered pager would have been a component that
   looks right on this page and cannot be built on any real site, which
   is the same trap as designing a form around a field type the builder
   does not have.

   So the two links sit at opposite ends of the row, which is what a
   pager without numbers has instead of a centre: the reader's position
   is expressed by which way they can still go.
   -------------------------------------------------------------------- */
.pager {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  margin-block-start: var(--space-16);
  padding-block-start: var(--space-8);
  border-block-start: var(--border-width) solid var(--color-border);
}

.pager__link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 2.75rem;
  padding-inline: var(--space-4);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-full);
  font-weight: var(--font-weight-bold);
  text-decoration: none;
  color: var(--color-text);
}

.pager__link:hover {
  border-color: var(--color-primary);
  color: var(--color-primary);
}

/* `auto` rather than `space-between`: on the first page there is only
   one link, and space-between would leave it stranded on the left where
   "older" does not belong. */
.pager__link--next {
  margin-inline-start: auto;
}

.pager__link .icon {
  width: 1.25rem;
  height: 1.25rem;
  transition: translate var(--motion-duration-slow) var(--ease-out-expo);
}

.pager__link--prev:hover .icon { translate: calc(-1 * var(--space-1)) 0; }
.pager__link--next:hover .icon { translate: var(--space-1) 0; }

/* --------------------------------------------------------------------
   Load more — the same next-page link, centred, that appends instead

   Centred rather than pushed to one edge, because it does not send the
   reader anywhere: it is the bottom of the list asking whether to
   continue, and a control that answers "here" belongs in the middle of
   the column it extends. The pager above is the opposite — it *is*
   travel, so it sits at the edges.
   -------------------------------------------------------------------- */
.load-more {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  margin-block-start: var(--space-16);
  padding-block-start: var(--space-8);
  border-block-start: var(--border-width) solid var(--color-border);
}

.load-more__status {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  /* Empty until something happens, and it should take no room until it
     does — a live region that reserves a blank line looks like a bug. */
  min-height: 0;
}

.load-more__status:empty {
  display: none;
}

/* --------------------------------------------------------------------
   Breadcrumbs — its own block since the trail left the subpage blocks

   The trail used to be the first line INSIDE every subpage block, which
   meant six blocks carried the same nav and none could be composed
   without it. It is one block now, fixed in place like the header
   (`data-block-fixed` — a trail that moved below its section would be
   nonsense) but hideable.

   The geometry is a handover. The block carries the subpage overture —
   the same `--space-16` the subpage sections used to open with — and the
   section below it drops to the small gap the trail always had to its
   heading. Hide the trail (or compose a page without it) and the sibling
   rule stops matching, so the section below reverts to its own full
   overture: the clearance under the floating header survives with or
   without the block, and nobody has to remember to compensate.

   `:not(.pv-off)` — a trail hidden in the preview panel must hand the
   padding back exactly like a trail that was never rendered, and a
   `display: none` corpse still matches `+`. Same reasoning as the merge
   rules; the one-corpse case is the only one a fixed block can produce.

   The block is classed `section--slim`, and that is load-bearing: the
   flat-surface rhythm merge excludes slim bands on both sides, so the
   trail neither halves the following section's padding (the merge's 44px
   was beating the 16px handover on specificity) nor invites a block
   above to close up against it. A one-line nav IS a slim band. */
/* `.section.block-crumbs`, not `.block-crumbs` — the slim band's own
   padding is declared in blocks.css, which loads after this file, so the
   override has to win on specificity rather than order. */
.section.block-crumbs {
  padding-block: var(--space-16) 0;
}

/* `:not(.section--panel)` — the handover is for a block that CONTINUES the
   page, whose top padding positions its heading under the trail. A panel
   is an object with an edge: that padding sits INSIDE the slab, so handing
   it the small gap crops its own content against its own rounded top
   instead of moving the slab. A panel keeps its padding and takes a gutter
   instead — see the panel rule below.

   The `.pv-off` chain is the preview-adjacency trap this project has
   already paid for once: a block hidden in the panel stays in the DOM
   under `display: none`, so `+` sees a corpse where production sees
   nothing and the handover silently stops. Enumerated to three, the same
   bound the flat-rhythm merge above uses — ugly, and honest in the way
   that matters: in production only the first clause can ever match, so
   the preview is not testing a different mechanism from the one that
   ships. */
.block-crumbs:not(.pv-off) + .section:not(.section--panel),
.block-crumbs:not(.pv-off) + .pv-off + .section:not(.section--panel),
.block-crumbs:not(.pv-off) + .pv-off + .pv-off + .section:not(.section--panel),
.block-crumbs:not(.pv-off) + .pv-off + .pv-off + .pv-off + .section:not(.section--panel) {
  padding-block-start: var(--space-4);
}

/* One module, aligned to what it introduces. Ahead of a block whose head
   sits in the reading column — the article, the reference detail, the
   text page — the trail narrows to the same measure and lands exactly
   where that heading starts; ahead of a listing or the contact page,
   whose `section-header` spans the container, it spans it too.

   **The predicate is the HEAD, not the block name.** Three heads declare
   `max-width: var(--measure); margin-inline: auto` and they are the whole
   reason the alignment differs, so the selector asks for them by name
   rather than enumerating the blocks that happen to carry them. A new
   block reusing one of those heads is aligned without anyone editing
   this rule; a block that stops using one loses the narrowing at the same
   moment it stops needing it.

   The constraint sits on the CONTAINER, not the `ol`, and the reason is
   the unit: `--measure` is in `ch`, which resolves against the element's
   own font — the trail is `--text-sm`, so 65ch on the `ol` lands 39px
   short of the head's 65ch at body size. The container inherits the body
   font, so its `ch` and the head's agree; the gutters ride on top, which
   is why the max-width adds two of them and the content box IS the
   measure. */
.block-crumbs:has(+ * .article__head) > .container,
.block-crumbs:has(+ * .block-reference__head) > .container,
.block-crumbs:has(+ * .block-text__head) > .container {
  max-width: calc(var(--measure) + 2 * var(--gutter));
}

.breadcrumbs ol {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--text-sm);
}

.breadcrumbs li:not(:last-child)::after {
  content: "/";
  margin-inline-start: var(--space-2);
  color: var(--color-text-muted);
}

.breadcrumbs a {
  color: var(--color-text-muted);
  text-decoration: none;
}

.breadcrumbs a:hover {
  color: var(--color-primary);
}

.breadcrumbs [aria-current="page"] {
  color: var(--color-text);
}

/* --------------------------------------------------------------------
   Contact form — composed of field primitives; carries GDPR consent
   -------------------------------------------------------------------- */
.contact-form {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: 1fr;
}

/* The actions row is a grid cell, so the button sits wherever the cell
   starts; `justify-items` on the row is what actually moves it. Paired
   with the consent row above so the two always agree. */
.contact-form__actions {
  display: flex;
  justify-content: var(--form-footer-align, flex-start);
}

@media (min-width: 40rem) {
  .contact-form { grid-template-columns: repeat(2, 1fr); }

  .contact-form .field--wide,
  .contact-form .field--checkbox,
  .contact-form .contact-form__actions {
    grid-column: 1 / -1;
  }
}
