/* Site-specific layer on top of the Modernist design system.
   Holds the small overrides that lived in the design reference's <helmet>
   <style> block, plus the real implementations of the prototype's
   <image-slot> placeholders and the YouTube/Vimeo facade. */

body { margin: 0; text-wrap: pretty; }

/* Custom scrollbar, styled to the palette instead of left as OS-default grey
   chrome. scrollbar-color/-width is the standardised cross-browser property
   (Chrome/Firefox/Edge); the ::-webkit-scrollbar-* pseudo-elements cover
   Safari, which doesn't implement the standard property. The thumb's own
   border matches the track colour, which is the usual trick for a thumb
   that reads as narrower/inset than the full scrollbar width rather than
   filling it edge to edge. */
html { scrollbar-color: var(--color-accent-600) var(--color-surface); scrollbar-width: thin; }
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: var(--color-surface); }
::-webkit-scrollbar-thumb {
  background: var(--color-accent-600); border-radius: var(--radius-md);
  border: 3px solid var(--color-surface);
}
::-webkit-scrollbar-thumb:hover { background: var(--color-accent-700); }

/* Headings break into visually even lines instead of leaving one word
   stranded; browsers without text-wrap support just wrap normally. */
h1, h2, h3 { text-wrap: balance; }

/* — inline body-copy links —
   The resting colour is accent-700 (#0d5680): 7.08:1 on --color-bg, which is
   AAA for normal text (this one colour; the site as a whole is AA, see the
   AAA paragraph in modernist.css's "light only" note). That was already the
   case; what was not, until the
   light-only pass of September 2026, was the hover state. It shifted to
   accent-600 (#106da3, the same value as base --color-accent), which reads
   5.04:1 — AA, and the weakest text colour anywhere on the page. A link that
   gets *harder* to read the moment you point at it is the wrong way round,
   so hover now moves one step darker instead of one step lighter:
   accent-800 (#0a4160), 9.72:1 on --color-bg and 8.63:1 on --color-surface.
   The direction of travel matches the rest of the theme, where every
   interaction state on a light ground sinks toward the ink rather than
   floating away from it, and `a:hover` in modernist.css brings the underline
   up to full strength at the same moment, so the affordance never rested on
   hue alone.

   Deliberately NOT changed with it: .btn-primary's white-on-accent label
   (5.44:1 resting, fine for a button) and the nav's own hover, which is
   chrome rather than body copy.

   :not(.btn) is what actually keeps that promise, and it is load-bearing
   rather than tidiness. `main a:hover` is (0,1,1); .btn-primary's own colour
   declaration is (0,1,0) and .btn-primary:hover only touches `background`.
   So without the exclusion the hover rule won on specificity and repainted
   every .btn-primary label inside <main> from #f7fcff to #0a4160 over an
   unchanged accent fill: 1.93:1 measured, on /, /events/ and /teaching/ in
   all three languages. The resting pair was fine only by accident (`main a`
   is (0,0,2) and loses to .btn-primary), which is precisely why the
   exclusion has to be on both declarations and not just the hover one: a
   later bump to either selector would have re-broken the resting state the
   same way. Buttons carry their own colour system; this rule is inline text
   links only, and now says so in the selector as well as in the comment. */
main a:not(.btn),
.ec-prose a:not(.btn) { color: var(--color-accent-700); text-underline-offset: 3px; }
main a:not(.btn):hover,
.ec-prose a:not(.btn):hover { color: var(--color-accent-800); }

/* — hero entrance —
   A brief fade + rise, homepage hero only (kicker, H1, lede) — not applied
   site-wide, since animating every page on load reads as busy rather than
   considered. fill-mode:both holds the "from" state through the delay (no
   flash of unstyled/full-opacity content before the animation starts) and
   the "to" state after it ends. prefers-reduced-motion is handled by the
   blanket override further down this file (covers animation-duration *and*
   -delay, see its own comment). */
@keyframes ec-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
.ec-rise-in { animation: ec-rise .6s var(--ease-premium) both; }
.ec-rise-in--1 { animation-delay: .05s; }
.ec-rise-in--2 { animation-delay: .15s; }
.ec-rise-in--3 { animation-delay: .25s; }

/* — shared page-header pattern —
   Extracted from the interior views' identical inline header blocks, which
   had quietly drifted apart (48/44/40px bottom paddings; intros at 16px/75%,
   17px/80% and 17px/82% on different pages). One source of truth now; the
   home hero keeps its own larger scale inline. */
.ec-wrap { max-width: 1180px; margin: 0 auto; padding: 0 clamp(20px, 5vw, 72px); }
.ec-page-head { padding: clamp(48px, 7vw, 88px) 0 clamp(24px, 4vw, 44px); }
.ec-kicker {
  display: block; font-size: 13px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--color-accent-700); margin: 0 0 20px;
}
.ec-h1 {
  font-family: var(--font-heading); font-weight: 800;
  font-size: clamp(36px, 5vw, 64px); line-height: 1.08;
  letter-spacing: -0.02em; margin: 0 0 24px -0.058em;
}
/* When a rule follows the H1 directly instead of an intro paragraph. */
.ec-h1--flush { margin-bottom: 0; }
.ec-intro {
  font-size: 16px; line-height: 1.65; margin: 0;
  color: color-mix(in srgb, var(--color-text) 75%, transparent);
}

/* — small uppercase labels —
   The same 13px label style existed inline with seven different margins.
   Two real roles: a tight label over a list (footer columns, contact aside)
   and a looser one opening a section (biography columns, press, cross-links). */
.ec-label, .ec-label--section {
  font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--color-accent-700); margin: 0 0 14px;
}
.ec-label--section { margin-bottom: 18px; }
/* Muted uppercase meta line (a project's scoring line, the news draft badge);
   previously two slightly different one-offs (0.06em at 55% and 60% ink). */
.ec-meta {
  font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase;
  /* 70%, not 60%: 60% reads 4.03:1 on --color-bg, under the 4.5:1 AA floor for
     this 13px meta text (axe-core 4.13 flags it; see modernist.css .text-muted). */
  margin: 0; color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* — pull quote —
   Originally a one-off inline style on the biography page's mid-paragraph
   quote; promoted to a shared class so press/teaching can reuse the same
   lift-a-line-out-of-the-copy treatment. aria-hidden at the call site (it
   repeats text already in accessible prose next to it) — same pattern as
   the ghost numeral below. */
.ec-pull-quote {
  position: relative;
  margin: 6px 0 30px; padding: 2px 0 2px 24px;
  border-left: 3px solid var(--color-accent-700);
  font-family: var(--font-heading); font-weight: 800;
  font-size: clamp(21px, 2.6vw, 27px); line-height: 1.32; letter-spacing: -0.01em;
  color: var(--color-text);
}
/* A large, low-opacity hanging quote mark — kept inside the block's own
   padding-left (not pushed past the border into the margin) so it can never
   overlap the surrounding column on a narrow viewport; the "hang" reads
   through the vertical offset (poking above the text) and oversized scale
   rather than a horizontal bleed. Decorative, so no aria-hidden needed of
   its own — ::before content is already invisible to screen readers. */
.ec-pull-quote::before {
  content: "\201C";
  position: absolute; left: 2px; top: -0.28em;
  font-size: 2.4em; line-height: 1;
  color: color-mix(in srgb, var(--color-accent-700) 30%, transparent);
  pointer-events: none; user-select: none;
}

/* — ghost numeral —
   An oversized, low-opacity numeral behind a row/card's own heading — a real
   magazine-style graphic layer, not just bigger inline text. The wrapper
   needs BOTH position:relative (containing block) AND isolation:isolate (own
   stacking context): the numeral sits at z-index:-1 so it paints behind the
   wrapper's ordinary static content regardless of that content's own
   position — without isolation, a negative z-index descendant can escape to
   the nearest ANCESTOR stacking context instead and paint behind the wrong
   thing entirely. overflow:hidden keeps the oversized glyph from bleeding
   into neighbouring rows or widening the page. Decorative only — the real
   number/heading text next to it carries the accessible content. */
/* Same "reading focus" tint as .table row hover — applied once here so
   home's work rows, projects' rows, teaching's cards and discography's
   records all pick it up for free through the one wrapper class they share. */
.ec-ghost-num-wrap { position: relative; isolation: isolate; overflow: hidden; transition: background-color .15s var(--ease-premium); }
.ec-ghost-num-wrap:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
.ec-ghost-num {
  position: absolute; z-index: -1; top: -0.1em; left: -0.02em;
  pointer-events: none; user-select: none;
  font-family: var(--font-heading); font-weight: 800; line-height: 1;
  letter-spacing: -0.03em;
  font-size: clamp(120px, 16vw, 220px);
  color: color-mix(in srgb, var(--color-accent) 14%, transparent);
}
/* The numeral is drawn as generated content from the data-ghost attribute,
   not as a real text node. It is purely decorative (the span is aria-hidden,
   and the visible heading beside it carries the meaning) and at 14 percent
   accent it sits well below the 4.5:1 contrast floor on purpose. A real text
   node at that ratio trips the axe and Lighthouse color-contrast check even
   though it is decorative; generated ::before content is not evaluated for
   contrast, so this renders identically while clearing the audit (the whole
   site went from Accessibility 96 to 100 on this one change). */
.ec-ghost-num::before { content: attr(data-ghost); }

/* — definition-list term marker —
   The only three <dl> blocks in the theme (biography's Training and Off-the-
   stage lists, press's Quick facts) were bold-term-over-muted-detail with no
   visual anchor. A small round dot — matching the design system's rounder
   shape language, not the square this was under the previous sharp-corner
   direction — gives each entry a start point. */
dl dt::before {
  content: "";
  display: inline-block;
  width: 6px; height: 6px;
  margin: 0 8px 1px 0;
  border-radius: 50%;
  background: var(--color-accent);
  vertical-align: middle;
}

/* Skip link — keyboard users land past the sticky nav. --color-accent is a
   background/graphic tone: it does pass AA against --color-bg (5.04:1, not
   the 3.98:1 this comment claimed, which was never accent's number at all:
   it was a misremembering of the 60%-ink figure, itself 4.03:1), but 5.04 is
   the thinnest margin in the theme and it is
   below AA on --color-surface at 4.47:1. accent-700 is the same swap already
   used for body links, at 7.08:1. */
.ec-skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--color-accent-700); color: var(--color-bg);
  padding: 10px 16px; font-family: var(--font-heading); font-weight: 800;
}
.ec-skip:focus { left: 8px; top: 8px; }

/* — image slot —
   Replaces the prototype's <image-slot> custom element. Renders as a real,
   layout-stable box (explicit aspect-ratio => no CLS) that shows the art-
   direction note until a real <img> is dropped in via the `image` filter. */
.ec-slot {
  position: relative; display: block; width: 100%;
  background:
    repeating-linear-gradient(45deg,
      color-mix(in srgb, var(--color-text) 5%, transparent) 0 10px,
      color-mix(in srgb, var(--color-text) 8%, transparent) 10px 20px);
  border: 1px solid var(--color-divider);
  /* The design system's radius token never reaches photos on its own (see
     modernist.css's --radius-* comment) — set explicitly here. overflow:
     hidden above already clips the <img> to it, no other change needed. */
  border-radius: var(--radius-md);
  overflow: hidden;
}
.ec-slot > img { width: 100%; height: 100%; object-fit: cover; }
.ec-slot__note {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  text-align: center; padding: 16px;
  font-size: 12px; line-height: 1.5; letter-spacing: 0.02em;
  /* 70%, not 60%: 60% reads 4.03:1 on --color-bg, under the 4.5:1 AA floor.
     Only visible on a slot with no image behind it, but the note is the one
     thing a reader would have to read in that state. */
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* — photography —
   Photos render in full colour, untinted: the sea-blue #ec-duotone filter
   this block used to carry was removed at the site owner's request. The
   `.grayscale` wrapper class the old scope hook matched survives in the
   views but matches no rule here anymore. What stays is the hover lift for
   photos that are real click targets (the press kit's downloadable photo
   grid) — scale feedback only, no colour change to reveal. */
a:hover .grayscale .ec-slot img,
a:focus-visible .grayscale .ec-slot img { transform: scale(1.04); }
.grayscale .ec-slot img { transition: transform .5s var(--ease-premium); }

/* — video facade —
   Poster + play button; the real iframe is swapped in on click by site.js.
   Keeps a single unloaded embed from costing more than the rest of the page
   (handoff §8, video). */
.ec-video { position: relative; cursor: pointer; }
.ec-video__play {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--color-neutral-900) 22%, transparent);
  border: 0; width: 100%; cursor: pointer;
}
/* --color-inverse, not --color-bg: the triangle sits on a dark scrim over a
   photo, not on the page — exactly the role --color-inverse exists for (see
   its comment in modernist.css, which names the play-triangle icon).
   --color-bg would happen to render the same light colour today and would
   still be the wrong token for it, since it describes the page ground rather
   than type on a non-page surface. Same token the live facade's
   .ec-vid__play::before already uses. */
.ec-video__play::before {
  content: ""; width: 0; height: 0;
  border-style: solid; border-width: 15px 0 15px 26px;
  border-color: transparent transparent transparent var(--color-inverse);
  filter: drop-shadow(0 1px 3px rgba(0,0,0,.4)); margin-left: 6px;
}
.ec-video__play:hover { background: color-mix(in srgb, var(--color-neutral-900) 34%, transparent); }
.ec-video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

/* ── video ────────────────────────────────────────────────────────
   Click-to-load facades. The poster is self-hosted and greyscaled to sit in
   the design; the video itself plays in colour once the iframe loads. Aspect
   ratios are declared on the frame so nothing reflows when it swaps in. */
/* Room for the sticky nav when a video is opened straight to (e.g. a press
   link to one clip's #v-<id> anchor) so the jump doesn't land underneath it. */
.ec-vid { margin: 0; scroll-margin-top: 88px; }
/* A press-kit or newsletter link lands on one specific clip (#v-<id>); ring
   it so the visitor sees which of the seven films they were sent to. Same
   ring the frame already uses for :focus-visible. */
.ec-vid:target .ec-vid__frame { outline: 2px solid var(--color-accent-700); outline-offset: 4px; }
.ec-vid__frame {
  position: relative; display: block; width: 100%; padding: 0;
  border: 1px solid var(--color-divider); background: var(--color-surface);
  cursor: pointer; overflow: hidden;
  border-radius: var(--radius-md);
}
.ec-vid--wide  .ec-vid__frame { aspect-ratio: 16 / 9; }
.ec-vid--short .ec-vid__frame { aspect-ratio: 9 / 16; }
.ec-vid__poster {
  width: 100%; height: 100%; object-fit: cover;
  /* Untinted like the photos (see "photography" above): the #ec-duotone
     filter this poster used to share with them is gone. Only the hover
     scale remains, so the transition covers transform alone. */
  transition: transform .5s var(--ease-premium);
}
.ec-vid__frame:hover .ec-vid__poster,
.ec-vid__frame:focus-visible .ec-vid__poster { transform: scale(1.04); }

/* Play triangle, centred, on an accent disc. */
.ec-vid__play {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  width: 64px; height: 64px; border-radius: 50%;
  background: color-mix(in srgb, var(--color-accent) 88%, transparent);
  display: grid; place-items: center;
  transition: background .2s var(--ease-premium), transform .2s var(--ease-premium);
}
.ec-vid__play::before {
  content: ""; width: 0; height: 0; margin-left: 4px;
  border-style: solid; border-width: 11px 0 11px 18px;
  border-color: transparent transparent transparent var(--color-inverse);
}
.ec-vid__frame:hover .ec-vid__play { background: var(--color-accent); transform: translate(-50%, -50%) scale(1.06); }
/* Press feedback (same idea as .btn:active in modernist.css): a shrink
   below resting size, distinct from hover's grow, so the two read as
   "reaching toward" vs. "pushing into" rather than the same motion twice. */
.ec-vid__frame:active .ec-vid__play { transform: translate(-50%, -50%) scale(0.96); }
.ec-vid__frame:focus-visible { outline: 2px solid var(--color-accent-700); outline-offset: 2px; }
.ec-vid--short .ec-vid__play { width: 52px; height: 52px; }

/* Running time, bottom right: promoters read these before they press play. */
.ec-vid__time {
  position: absolute; right: 8px; bottom: 8px;
  font-family: var(--font-heading); font-weight: 600; font-size: 12px;
  letter-spacing: 0.02em; padding: 2px 7px;
  color: var(--color-inverse);
  background: color-mix(in srgb, var(--color-neutral-900) 78%, transparent);
}

.ec-vid__cap { display: block; margin-top: 12px; }
.ec-vid__title {
  display: block; font-family: var(--font-heading); font-weight: 800;
  font-size: 16px; line-height: 1.25;
}
.ec-vid__note {
  display: block; margin-top: 4px; font-size: 14px; line-height: 1.5;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* The loaded iframe reuses the frame's box, so the swap is seamless. */
.ec-vid__frame iframe,
.ec-vid .ec-vid__frame > iframe { width: 100%; height: 100%; border: 0; display: block; }

/* Grids. Wide videos sit two-up on desktop; Shorts stay narrow so a vertical
   clip never balloons to full width on a large screen. */
/* Two up from tablet width. Fixed rather than auto-fit: with four secondary
   films, auto-fit lands on a lopsided 3 + 1 at desktop width. */
.ec-vid-grid {
  display: grid; gap: 32px clamp(20px, 3vw, 32px);
  grid-template-columns: 1fr;
}
@media (min-width: 620px) {
  .ec-vid-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.ec-vid-grid--shorts {
  grid-template-columns: repeat(auto-fit, minmax(190px, 240px));
  justify-content: start;
}
@media (max-width: 560px) {
  /* No .ec-vid-grid single-column rule here: the base declaration above is
     already 1fr and the two-column rule is min-width:620px, so at 560px the
     grid is single-column with nothing to override. */
  /* Shorts go one-up on a phone. Two side by side "still read fine" as
     posters, which is what this rule used to say — but these are facades,
     and the iframe that replaces one on click inherits the frame's box. A
     9/16 clip in a half-width column played at 158x282, 14.6% of a 375x812
     screen, while a 16:9 clip beside it got the full 335px content width:
     the portrait format, the one shot for phones, was rendering smaller
     than the landscape one. Measured, not eyeballed.

     Width is min(100%, 38vh) rather than a flat 100% so the cap is set by
     the screen's HEIGHT, which is what actually constrains a portrait
     video: at 9/16, 38vh of width is ~68vh of height, so the clip is large
     but never taller than the viewport on a short phone (38vh = 309px on an
     812px screen, 253px on a 667px SE). A flat 100% would be 596px tall
     here — fine on this screen, over the top of a smaller one.

     The desktop rule this overrides caps Shorts at 240px for the opposite
     reason (a vertical clip must not balloon across a wide monitor); that
     intent is about large screens and is untouched. */
  .ec-vid-grid--shorts {
    grid-template-columns: minmax(0, min(100%, 38vh));
    gap: 24px;
  }
  .ec-vid__play { width: 52px; height: 52px; }
}

/* ── home hero ────────────────────────────────────────────────────
   A full-bleed photograph under the kicker and the H1, edge to edge on phone
   and desktop alike.

   Full width without tricks: the hero sits outside .ec-wrap in views/home.php
   and <main> has no width of its own, so it simply fills the page. No
   `width: 100vw` and no negative-margin escape, both of which overflow by the
   width of the scrollbar on Windows and produce the horizontal scrollbar this
   layout must not have.

   HEIGHT uses small-viewport units. On a phone, `vh` is the LARGE viewport
   (URL bar hidden), so a 100vh-family hero is taller than the screen on load
   and the page jumps as the bar collapses; `svh` is the small viewport, which
   is the one you actually see first. The plain `vh` line above each `svh` line
   is the fallback for engines without the new units: same number, and the
   worst case there is a hero slightly taller than intended, never shorter. */
.ec-hero {
  position: relative;
  width: 100%;
  overflow: hidden;
  background: var(--color-text);
  /* Pulled up under the nav so the bar sits ON the picture. --ec-nav-h now
     carries a real CSS default per breakpoint (see its own block above the
     nav rules); site.js still measures and corrects it, but the layout is
     right at first paint instead of jumping when the deferred script runs.

     What that changes for a visitor with no JavaScript: the picture is pulled
     up under an ordinary SOLID bar rather than starting below it, so the top
     74px (122 on a phone) of the photograph sits behind the nav. The copy is
     at the foot of the hero and unaffected, and nothing becomes unreadable,
     because the transparent state is still the thing only the script can turn
     on: [data-ec-over-hero] is never set, so the bar keeps its --color-bg
     ground and its ink text. That is still the safe direction. The old 0px
     default kept the whole picture visible without JavaScript and cost every
     visitor a 0.22 layout shift to do it, which is the wrong trade. */
  margin-top: calc(-1 * var(--ec-nav-h, 0px));
  height: 62vh;
  height: min(62svh, 520px);
  min-height: 320px;
}
@media (min-width: 721px) {
  .ec-hero { height: 84vh; height: min(84svh, 760px); }
}
.ec-hero__media { position: absolute; inset: 0; }
/* The slot's own aspect-ratio, border and radius are for an in-flow picture
   box; here the box is the hero. width/height:100% are the point of this rule
   and not decoration: ec_slot() writes aspect-ratio as an inline style, and
   inset:0 alone does NOT beat it (the height stays `auto`, so the ratio still
   sizes the box, and the picture overflowed the hero by 87px at 1280x900,
   cropping from the bottom instead of from the centre). Two definite
   dimensions make the ratio inert, with no !important needed against the
   inline style. */
.ec-hero__media .ec-slot {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  border: 0; border-radius: 0; background: none;
}
.ec-hero__media .ec-slot > img { width: 100%; height: 100%; object-fit: cover; }
/* The scrim the type sits on. Weighted to the bottom, where the copy is, and
   gone entirely above it. Strength was set by measurement, not by eye: see the
   ratios in .ec-hero__title below.

   It used to trail all the way to the top edge (50% ink at 55%, 20% at 78%),
   which left 2% of the hero untinted and pulled every photograph towards
   --color-text, a dark navy. The client's note in September 2026 was that it
   took the colour out of the picture, and on the glass-roof hero that is
   exactly what it did: her face, the gilded action and the yellow shirt all
   sit in the upper half.

   The first three stops below are unchanged, so the band the type occupies is
   composited identically: measured over the real hero file at 1440x900,
   1280x800, 1920x1080, 768x1024 and 390x844, the worst-case ratio against
   --color-inverse is 5.57:1 for the kicker (small text, needs 4.5) and 6.86:1
   for the H1 (large, needs 3) — at or just above what the old curve gave. What
   changed is only the tail: the fade ends at 60% instead of running to 100%,
   so the top 43% of the picture carries no ink at all. The type band reaches
   about 42% from the bottom at the widest H1 (two lines of 82px at 1440px),
   which is why the last unchanged stop is at 42% and not lower. If the H1 ever
   grows a third line, re-measure before moving these. */
.ec-hero::after {
  content: "";
  position: absolute; inset: 0;
  pointer-events: none;
  background: linear-gradient(
    to top,
    color-mix(in srgb, var(--color-text) 90%, transparent) 0%,
    color-mix(in srgb, var(--color-text) 82%, transparent) 28%,
    color-mix(in srgb, var(--color-text) 66%, transparent) 42%,
    color-mix(in srgb, var(--color-text) 18%, transparent) 48%,
    color-mix(in srgb, var(--color-text) 3%, transparent) 55%,
    transparent 60%);
}
.ec-hero__copy {
  position: absolute; z-index: 1;
  left: 50%; transform: translateX(-50%);
  bottom: clamp(28px, 4.5vw, 64px);
  width: 100%; max-width: 1180px;
  /* Same gutter as .ec-wrap, so the H1 starts on the same vertical line as
     the lede paragraph that follows it below the fold. */
  padding: 0 clamp(20px, 5vw, 72px);
}
/* --color-inverse, not --color-bg: this type sits on a photograph, not on the
   page. Same role the video play triangle and the running-time badge use. */
.ec-hero__copy .ec-kicker { color: var(--color-inverse); margin: 0 0 16px; }
.ec-hero__title {
  font-family: var(--font-heading); font-weight: 800;
  font-size: clamp(38px, 6.2vw, 82px); line-height: 1.06;
  letter-spacing: -0.022em; margin: 0 0 0 -0.058em;
  color: var(--color-inverse);
}
/* A zero-height marker at the foot of the hero. site.js watches it with an
   IntersectionObserver to know when the picture has gone by; nothing here
   depends on it, and with no script it is an empty box. */
.ec-hero__sentinel {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; pointer-events: none;
}

/* — photo strip (media page) —
   Six square crops: one row of six on a wide screen, two rows of three below
   it, three rows of two on the narrowest phones. The 1/1 ratio is set on the
   slot itself, so every picture crops to the same square whatever its source
   shape (portraits and landscapes are mixed here on purpose) and the row
   reads as a strip rather than a ragged set of tiles.

   EXPLICIT COLUMN COUNTS, not repeat(auto-fit, minmax(96px, 1fr)), which is
   what this was and which does not do what the comment above it used to
   claim. auto-fit fits as many 96px tracks as it can and then stretches them,
   so the count fell through every value on the way down rather than the three
   it was supposed to: six across to 720px, then FIVE plus a lone orphan on
   the second row from 620 to 660, four plus two from 480 to 560, and three
   plus three at 375. A strip of six only reads as a strip at a factor of six,
   and a 5+1 is the shape that made it look broken. Six, three, two: every
   count divides the set evenly, and no row is ever left with one tile in it. */
.ec-photo-strip {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(10px, 1.6vw, 18px);
}
/* Below 360px three tiles are under 90px each; two is the readable floor. */
@media (max-width: 359px) {
  .ec-photo-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 720px) {
  .ec-photo-strip { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
/* Each tile is a link to the gallery (views/media.php). The anchor is the
   grid item, so it has to fill its track or the picture would sit in a box
   only part of which is clickable. */
.ec-photo-strip a {
  display: block; border-radius: var(--radius-md);
  text-decoration: none;
}
.ec-photo-strip a .ec-slot > img { transition: transform .5s var(--ease-premium); }
.ec-photo-strip a:hover .ec-slot > img,
.ec-photo-strip a:focus-visible .ec-slot > img { transform: scale(1.05); }
/* outline-offset clears the slot's own 10px radius so the ring reads as a
   ring rather than as four corners cut off by the picture. */
.ec-photo-strip a:focus-visible { outline: 2px solid var(--color-accent-700); outline-offset: 3px; }

/* — nav "Booking" button (.btn-primary) —
   `.nav a { color: inherit }` (modernist.css) outranks `.btn-primary`'s own
   color by specificity (0,1,1 beats 0,1,0), so the button was silently
   rendering body-ink text on its blue ground: 3.73:1, under the 4.5:1 AA
   floor. Fixed here at nav-scoped specificity; background moves one step
   darker too, since --color-accent under --color-bg text reaches only
   5.04:1, not the 3.98:1 this comment used to claim (which was a stray copy
   of the muted-ink figure, itself 4.03:1 at 60%), the thinnest pairing in
   the theme, where accent-700 gives 7.08:1. Hover/active keep
   the same one-step-darker-per-state rhythm as the component's own rule,
   shifted into AA-safe territory. */
.nav .btn-primary { background: var(--color-accent-700); color: var(--color-bg); }
.nav .btn-primary:hover { background: var(--color-accent-800); color: var(--color-bg); }
.nav .btn-primary:active { background: var(--color-accent-900); color: var(--color-bg); }

/* — the nav's height, as a number CSS knows before the script runs —
   --ec-nav-h is what .ec-hero subtracts as a negative top margin to pull the
   picture up under the bar. It used to have no value but the 0px fallback in
   that one calc(), and assets/js/site.js published the real number from
   nav.offsetHeight. The script is deferred, and `defer` does not block
   painting: the browser laid the hero out below a solid bar, painted it, then
   ran the script and jumped the hero and everything under it up by the nav's
   height. Measured layout shift on the home page: CLS 0.2207 at 768px,
   0.1465 at 1024, 0.0578 at 1280 (Google's "poor" band starts at 0.25), and
   0 on the eleven pages with no hero.

   So the property gets a real default here, per breakpoint, and the script
   only corrects it. The numbers are not guesses: the bar is one row of 48px
   touch targets (.nav a:not(.ec-lang):not(.btn) and .ec-lang both carry
   min-height: 48px) inside .nav's 12px vertical padding, over a 2px bottom
   border, so 48 + 24 + 2 = 74px. Below 721px .ec-nav__actions takes a second
   row of its own and row-gap is 0, so 48 + 48 + 24 + 2 = 122px. After the
   collapse fix above there are only those two heights left at any width,
   which is what makes a CSS default possible at all: before it the bar could
   be 74, 150 or 226px depending on where the eight links happened to wrap.

   The script still measures, because a longer translated "Menu" label or a
   visitor's larger default font size can move the bar by a pixel or two, and
   a correction that lands in the same frame costs nothing. It sets the
   property on <html> as an inline style, which outranks these rules. */
:root { --ec-nav-h: 74px; }
@media (max-width: 720px) {
  :root { --ec-nav-h: 122px; }
}

/* — primary nav —
   The layout that used to sit inline on the <nav> in inc/render.php, moved
   here so the phone breakpoint can override it without !important. Sticky
   on desktop; on phones it is a plain relative box (a three-row wrapped nav
   was eating a third of the first screen when sticky) that also serves as
   the containing block for the menu panel below. */
.ec-nav {
  position: sticky; top: 0; z-index: 20; background: var(--color-bg);
  padding-inline: clamp(20px, 5vw, 72px); gap: 28px; flex-wrap: wrap;
}
.ec-nav-menu__links { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }

/* — nav over the home hero —
   On the home page the bar starts transparent on the photograph and takes its
   normal background and bottom rule once the hero has gone by. Every rule in
   this block hangs off [data-ec-over-hero], which ONLY site.js ever sets, and
   only after it has measured the nav and found a hero. With no JavaScript the
   attribute never appears, nothing below matches, and the nav renders exactly
   as it does on every other page: solid --color-bg, ink text, bottom border.
   That is the direction the enhancement has to fail in.

   The transition is declared on .ec-page--home .ec-nav rather than .ec-nav so
   it outranks the phone block's transform-only transition further down (equal
   specificity, later in the file); transform is repeated here so the phone
   nav's hide-on-scroll still slides. prefers-reduced-motion is handled by the
   blanket rule near the end of this file, which zeroes every transition
   duration, so the state change snaps instead of fading.

   COLOUR IS A STEP, NOT A FADE, and the delay on that step is measured.
   `color` used to be in this list on the same .28s curve as the background,
   so on every crossing of the hero boundary the ink interpolated
   #f7fcff -> #0e2839 while the ground interpolated
   transparent-over-photograph -> #eaf4fb, and the two crossings met in the
   middle: the links sat below 4.5:1 for about 110ms and passed through
   1.08:1. A cross-fade between two readable states is not readable in
   between, because contrast is a ratio and does not interpolate the way a
   colour ramp does.

   The step is at 33ms, and 33 is not a round number picked to look measured.
   The nav band was photographed frame by frame through the whole state
   change, with the nav's text forced transparent so every frame is the pure
   composited ground, and each frame scored for BOTH candidate inks against
   its own worst pixel inside the glyph boxes (the brightest ground for the
   light ink, the darkest for the dark ink). Frames were indexed by the
   transition's own progress -- readable per frame as the scrim's animated
   opacity -- rather than by wall clock, because a stalled first frame after
   a style change offsets every later sample and would have put the answer
   80ms out. Leaving the hero, at 1280px:

       progress   0.00   0.36   0.60   0.76   0.86   0.92   1.00
       t (ms)      0.0   24.4   46.6   68.6   89.9  111.3  280.0
       light ink   6.26   2.99   1.95   1.52   1.31   1.21   1.08
       dark ink    1.07   1.63   3.45   5.99   8.47  10.23  13.63

   The two curves cross at progress 0.454, which on this curve is t = 32.5ms,
   and they cross AT 2.55:1. Returning to the hero gives 0.449 and 2.49:1.
   That crossing value is the ceiling on what any single step can guarantee,
   and stepping anywhere else is strictly worse: the same measurement with the
   step at 20ms bottomed out at 1.55:1, and the 180ms an "after the background
   is opaque" reading would suggest holds the LIGHT ink over an already-light
   ground for 150ms and bottoms out at 1.35:1.

   Re-measured with the step in place, reading the ink and the ground from the
   same frame, the dip is 2.91:1 for 22ms leaving the hero and 3.00:1 for 43ms
   returning to it. Before this change: 110ms, through 1.08:1.

   It cannot be made to never dip, and that is arithmetic rather than
   defeatism. The dark ink (#0e2839, relative luminance 0.0227) needs a ground
   above 0.272 to clear 4.5:1; the light ink (#f7fcff, 0.968) needs one below
   0.176. Those two windows do not overlap, so every dark-to-light ground
   crossing passes through a band no ink in this palette can carry, whatever
   the timing. Even pure black and pure white would only overlap between 0.175
   and 0.183, a 0.8% window, which is not a design. The one way to a literal
   never-below-4.5 is to drop the ground cross-fade and swap the whole bar in
   a single frame; that is a decision about the round-3 hero treatment rather
   than a bug fix, so it is written down here instead of taken. */
.ec-page--home .ec-nav {
  transition: background-color .28s var(--ease-premium),
              border-bottom-color .28s var(--ease-premium),
              color 0s linear .033s,
              transform .25s var(--ease-premium);
}
/* The nav's own `color` is only half of it: `.nav a { color: inherit }` is
   what carries most of the links, but .ec-nav-brand, .ec-lang and
   .ec-nav[data-ec-over-hero] a:not(.btn) all name a colour explicitly, and
   the base `a` rule in modernist.css transitions `color` over .2s. That is a
   second, independent fade on exactly the elements this block is trying to
   step, and it reintroduced the grey-out on its own. Same step here, on the
   anchors, with text-decoration-color left on its own curve (the underline
   is not load-bearing for contrast). .btn is excluded so the Booking chip
   keeps the component's full transition list. */
.ec-page--home .ec-nav a:not(.btn) {
  transition: color 0s linear .033s,
              text-decoration-color .2s var(--ease-premium);
}
/* The nav's own scrim: a short top-down darkening so the links keep their
   contrast over a bright part of the picture. Always present on the home nav
   but at zero opacity, so it can fade with the rest of the state instead of
   popping in with the attribute. z-index:-1 keeps it behind the nav's content
   and inside the nav's own stacking context (the bar already carries
   z-index:20, so the negative index cannot escape to an ancestor). */
.ec-page--home .ec-nav::before {
  content: "";
  position: absolute; left: 0; right: 0; top: 0;
  /* An absolute height, not 260% of the bar. A percentage was tied to a nav
     that ranged from 74px to 226px, so the scrim ranged from 192px to 588px:
     25% of the hero at 1440px and 77% of it at 768px, taking up to 76% of the
     picture's light out of the top of the photograph the round exists to
     show. min(190px, 26vh) is sized for the job it actually has, which is to
     darken the band the links sit in (74px, or 122px when the collapsed bar
     is two rows on a phone) and fade out below it. 26vh is the short-screen
     guard: on a 667px iPhone SE 190px would be 28% of the viewport. */
  height: min(190px, 26vh);
  z-index: -1;
  pointer-events: none;
  opacity: 0;
  transition: opacity .28s var(--ease-premium);
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--color-text) 86%, transparent) 0%,
    color-mix(in srgb, var(--color-text) 66%, transparent) 38%,
    color-mix(in srgb, var(--color-text) 28%, transparent) 74%,
    transparent 100%);
}
.ec-nav[data-ec-over-hero] {
  background: transparent;
  border-bottom-color: transparent;
  color: var(--color-inverse);
}
.ec-page--home .ec-nav[data-ec-over-hero]::before { opacity: 1; }
/* `.nav a { color: inherit }` carries most of the links for free; these are
   the four rules elsewhere in the theme that name a colour explicitly and
   would otherwise paint ink or accent-700 on the photograph. The Booking
   button is deliberately absent: it is a solid accent-700 chip with
   --color-bg text (7.08:1) and reads on any ground, so it stays put and gives
   the transparent bar one anchored object. */
.ec-nav[data-ec-over-hero] .ec-nav-brand,
.ec-nav[data-ec-over-hero] a:not(.btn),
.ec-nav[data-ec-over-hero] a:not(.btn):hover,
.ec-nav[data-ec-over-hero] .ec-lang,
.ec-nav[data-ec-over-hero] .ec-lang[aria-current='true'],
.ec-nav[data-ec-over-hero] .ec-lang[aria-current='true']:hover,
.ec-nav[data-ec-over-hero] .ec-nav-menu > summary {
  color: var(--color-inverse);
}
/* Hue is the active language's only signal in the normal bar (accent-700
   against 70% ink). On the photograph every code is the same light colour, so
   the state moves to an underline instead of being lost. Hover gets one too,
   for the same reason. */
.ec-nav[data-ec-over-hero] .ec-lang[aria-current='true'] {
  text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1px;
}
.ec-nav[data-ec-over-hero] a:not(.btn):hover {
  text-decoration: underline; text-underline-offset: 5px;
}
/* — focus ring on the transparent bar —
   WCAG 2.2 SC 1.4.11: a focus indicator has to reach 3:1 against the pixels
   it replaces. The global ring (modernist.css :focus-visible) is
   --color-accent-700, #0d5680, a dark navy chosen against the light page.
   Drawn on a scrim-darkened photograph it measured worst 1.01:1 and median
   1.63:1: on the hero it was, in practice, not there.

   Two rings, not one, so the indicator cannot depend on what the photograph
   happens to be doing: the outline itself goes light (--color-inverse), and a
   4px halo of 70% page ink sits immediately outside it. Whatever the picture
   is at that point, one of the two edges is against it. The measured ratios
   for both are in the round-4 notes. Scoped to [data-ec-over-hero], so the
   ordinary bar keeps the site-wide accent ring and nothing changes on the
   other 35 pages. */
.ec-nav[data-ec-over-hero] :focus-visible {
  outline-color: var(--color-inverse);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-text) 70%, transparent);
}
/* The Booking chip carries its own --shadow-sm; replacing it wholesale with
   the halo would flatten the one raised object on the bar, so it keeps both. */
.ec-nav[data-ec-over-hero] .btn-primary:focus-visible {
  box-shadow: var(--shadow-sm), 0 0 0 4px color-mix(in srgb, var(--color-text) 70%, transparent);
}
/* The current page's link: accent-700 (7.08:1 on the bar's ground), not the
   modernist.css hover/active accent-600, which reads 5.04:1 there. 600 does
   clear AA; the 3.98:1 this comment used to quote belonged to no colour on
   this page, but the current page is a state a visitor reads
   rather than points at, and 700 is the step the rest of the theme uses for
   it. It stays that colour on hover, as the old inline style did. */
.ec-nav-menu__links a[aria-current='page'] { color: var(--color-accent-700); }
.ec-nav__actions { display: flex; align-items: center; gap: 14px; }

/* — collapsing menu —
   The eight section links live inside a <details>, server-rendered open so
   the nav is complete without JavaScript and at every width where the whole
   bar fits on one row (there the <summary> is simply hidden). Below the
   collapse breakpoint site.js closes it on load and marks it data-ready; the
   <summary> then acts as the Menu button (native disclosure semantics:
   focusable, Enter/Space, expanded state announced) and the links drop down
   as a panel over the page instead of pushing it. Until data-ready is set the
   panel and the button stay hidden, so the server-rendered open state never
   flashes the full list before the script runs; a <noscript> style in
   functions.php shows the links in flow instead when there is no script at
   all.

   THE BREAKPOINT IS 1280px, not the 720px it was until this round, and the
   number is measured rather than chosen. Below it the flex-wrap on .ec-nav
   did what it was told and wrapped: 150px of two rows from about 850px, and
   226px of THREE left-aligned rows at 768px, each with its right half empty,
   sitting on top of the home page's hero photograph. A horizontal bar is
   only a bar while every item is on the line, so the breakpoint has to sit
   above the widest single row the eight links can produce, in the widest
   language.

   Measured at the breakpoint itself (brand + gap + links + gap + actions +
   the bar's own clamped padding), one row needs

       EN 1159.9    IT 1164.3    FR 1199.5

   French is the constraint, and it is why this is 1280 and not the 1200 the
   round-4 brief asked for as a floor: at 1200 the French bar fits in 1200.0
   with 0.5px to spare, which is not a margin, it is a coincidence. One more
   character in one label, or a visitor whose Work Sans never arrives and who
   gets the metric-compatible Arial fallback instead, and it wraps again.
   1280 leaves French 72.5px, about 6%.

   It must stay in step with NAV_COLLAPSE_MQ in assets/js/site.js, which is
   what closes the <details> and sets data-ready, and with the no-JS
   fallback style in functions.php, which is what puts the links back when
   the script never runs. The three are one decision. 1279.98px rather than
   1279px so a fractional viewport width between the two cannot fall through
   both queries. */
.ec-nav-menu > summary { display: none; }
.ec-nav-menu > summary::-webkit-details-marker { display: none; }
@media (max-width: 1279.98px) {
  /* The Menu button goes last in the bar, after the language codes and the
     Booking chip. No margin-left:auto with it: .ec-nav-brand already carries
     margin-right:auto, which pushes everything after it to the right edge as
     one group. A second auto margin here split that group in two and left the
     languages and Booking stranded in the middle of the bar with a gap on
     either side, which is what it looked like: three separate objects rather
     than a wordmark on the left and the controls on the right. */
  .ec-nav-menu { order: 1; }
  /* The links stay out until the script is ready, so the server-rendered
     open list never flashes before the panel styling exists. The BUTTON is
     laid out from the first paint and only made invisible, because
     display:none there meant the bar was one Menu-button narrower until the
     deferred script ran and then jumped: a small shift, but a real one
     (0.0007 at 768px, 0.0005 at 1024, both sourced to .ec-nav__actions), and
     the only one left on the page after the --ec-nav-h default above.
     visibility:hidden reserves the box without making the control real: it
     is out of the accessibility tree and cannot be clicked or focused, so a
     visitor whose script never arrives gets a gap rather than a button that
     reveals nothing. */
  .ec-nav-menu:not([data-ready]) > summary { visibility: hidden; }
  .ec-nav-menu:not([data-ready]) > .ec-nav-menu__links { display: none; }
  .ec-nav-menu > summary {
    display: inline-flex; align-items: center; gap: 10px; list-style: none;
    min-height: 48px; padding: 0 4px; cursor: pointer;
    font-family: var(--font-heading); font-weight: 800; font-size: 14px;
    letter-spacing: 0.02em; user-select: none;
  }
  /* Not [data-ready] either, and for the same reason: the chevron is 8px plus
     a 10px gap of the button's width, so gating it on the script would have
     put back most of the shift the rule above removes. It inherits the
     summary's visibility, so it is hidden with it. */
  .ec-nav-menu > summary::after {
    content: ""; width: 8px; height: 8px; margin-top: -4px;
    border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
    transform: rotate(45deg); transition: transform .2s var(--ease-premium);
  }
  .ec-nav-menu[data-ready][open] > summary::after { transform: rotate(-135deg); margin-top: 4px; }
  .ec-nav-menu[data-ready] > .ec-nav-menu__links {
    position: absolute; left: 0; right: 0; top: 100%; z-index: 30;
    flex-direction: column; align-items: stretch; gap: 0;
    padding: 6px clamp(20px, 5vw, 72px) 14px;
    background: var(--color-bg);
    border-bottom: 2px solid var(--color-divider);
    /* --shadow-md, not --shadow-lg: lg is dialog weight (0 12px 32px), sized
       for something floating in the middle of the viewport. Twelve pixels of
       vertical offset detaches the shadow from the edge it is meant to sit
       under, so beneath the panel's own 2px border it painted a second,
       softer band with a clear gap between the two — read as a double rule
       under the menu rather than as depth, which is exactly what it was
       reported as. md's 3px offset tucks the shadow under the border, so the
       panel still lifts off the page but the edge is one line. */
    box-shadow: var(--shadow-md);
  }
  .ec-nav-menu[data-ready] > .ec-nav-menu__links a {
    font-size: 16px;
    border-top: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
  }
  .ec-nav-menu[data-ready] > .ec-nav-menu__links a:first-child { border-top: 0; }
  /* On the home hero the bar's links go light (see the [data-ec-over-hero]
     block above), but while the menu is collapsed those same links live
     inside the drop-down panel, which has a solid --color-bg of its own:
     light-on-light. The panel is page surface, not photograph, so its links
     keep page ink. Only the Menu summary, which really does sit on the
     picture, stays light. */
  .ec-nav[data-ec-over-hero] .ec-nav-menu[data-ready] > .ec-nav-menu__links a,
  .ec-nav[data-ec-over-hero] .ec-nav-menu[data-ready] > .ec-nav-menu__links a:hover {
    color: var(--color-text); text-decoration: none;
  }
}

/* — phone nav: sticky, but only while you are heading back up —
   This was `position: relative` on phones, because the nav wraps to two rows
   there (brand + Menu, then language + Booking) and 122px of a 812px screen
   is 15% of the viewport to surrender permanently. That reasoning still
   holds; what it left behind was the opposite problem, on the pages where it
   hurts most: /events/ is about 7900px tall, roughly ten screens, and once
   the nav had scrolled away there was no route to another section or to
   Booking except flicking all the way back to the top.

   So it is sticky again, and site.js translates it out of view while the
   visitor scrolls *down* and brings it straight back on the first upward
   scroll — the direction that means "I am looking for something". Reading
   costs nothing; wanting the nav costs one flick. The transform (not
   top/display) keeps it on the compositor, and translating by -101% hides
   the 2px bottom border along with it.

   Everything degrades: without JavaScript data-nav-hidden is never set, so
   the nav is simply always-sticky rather than broken, and the reduced-motion
   block further down zeroes the transition so it snaps instead of sliding. */
@media (max-width: 720px) {
  .ec-nav {
    position: sticky; top: 0; gap: 0 16px;
    transition: transform .25s var(--ease-premium);
  }
  .ec-nav[data-nav-hidden] { transform: translateY(-101%); }
  /* Deep links (#v-<id>, #p-<slug>) land under a nav that may still be on
     screen at the moment of the jump; 88px was sized for the desktop bar,
     and below 721px the bar is the two-row phone shape (122px). */
  .ec-vid, .ec-project { scroll-margin-top: 130px; }
  /* Language switcher left, Booking hard right, on their own row. Only on a
     real phone: from 721px up the collapsed bar is brand, Menu, languages and
     Booking on ONE row, which is the shape the collapse exists to protect. */
  .ec-nav__actions { order: 2; flex: 1 0 100%; justify-content: space-between; }
}

/* — inverse button —
   For controls that sit on the CTA band's accent gradient: a solid light
   button reads
   as the band's one action where the old 1px ghost outline at 14px all but
   disappeared against the gradient. Both tokens are ground-independent
   (--color-inverse is type-on-a-non-page-surface, the accent-2 ramp is the
   band itself), so the button is described by what it sits on rather than by
   the page it happens to sit in. */
.btn-inverse {
  background: var(--color-inverse); color: var(--color-accent-2-800);
  box-shadow: var(--shadow-sm); padding: 12px 22px; font-size: 15px;
}
.btn-inverse:hover { background: color-mix(in srgb, var(--color-inverse) 70%, white); color: var(--color-accent-2-900); box-shadow: var(--shadow-md); }
.btn-inverse:active { background: var(--color-inverse); }
/* On the band the global :focus-visible ring (accent-700) would vanish into
   the band; ring in the light colour instead. */
.btn-inverse:focus-visible { outline-color: var(--color-inverse); }

/* — touch-size buttons —
   .btn is 33px tall by design on desktop (a compact editorial button next
   to 14px text). On a touch screen that is under the 44px target floor the
   rest of the theme already honours for nav links, the language switcher
   and table actions, so on coarse pointers every button grows to it. */
@media (pointer: coarse) {
  .btn { min-height: 44px; padding-inline: 18px; }
  /* The contact form is the one place the site asks for input; its fields
     and topic radios deserve the same 44px floor as the buttons. The radio
     is inline-flex with centred items, so the extra height becomes hit
     area around the unchanged 16px dot, not a bigger control. */
  /* font-size is the other half of this, and the more consequential half:
     iOS Safari zooms the whole page in when a field smaller than 16px takes
     focus, and it does not zoom back out afterwards — so tapping "Name" on
     an iPhone left the visitor filling in a booking enquiry on a page they
     now had to pan sideways to read. The component's 14px is a desktop
     decision (it matches the .btn label it sits beside in sign-up rows);
     16px here is the touch-side value, and only .input needs it, since it
     is the only element type that triggers the behaviour. */
  .input { min-height: 44px; font-size: 16px; }
  .radio { min-height: 44px; }
  /* The footer link columns and the cross-link row are the site's wayfinding
     whenever the phone nav is retracted (it hides on downward scroll — see
     the "phone nav" block), and they were the last link lists still sized
     for a mouse: 16px and 18px tall respectively. Both clear WCAG 2.2's 24px
     target-size minimum on the spacing exception, so this is not a
     conformance fix — it is the same 44px+ floor the nav links, language
     switcher and table actions already hold, applied to the two lists that
     had been missed. Scoped to coarse pointers so the desktop footer keeps
     its tighter line-height-2 rhythm. */
  .ec-footer__list a,
  .ec-crosslinks__item a { display: inline-flex; align-items: center; min-height: 44px; }
  /* — onward links —
     The "→" links that carry a visitor from one section to the next: home's
     three work-row CTAs and its Full calendar / All video headers, events'
     All video, and each project's Watch / See upcoming dates. Same kind of
     control as .ec-crosslinks__item above, and on the homepage literally
     the same shape sitting a few hundred pixels apart — which is how the
     gap showed up: the four cross-links at the foot of / measured 44px
     while the three work-row CTAs above them measured 18px, identical
     arrows at half the hit area.

     A class rather than a structural selector on purpose. The tempting
     shorthand, `main p > a:only-child`, silently catches ordinary prose
     links too: :only-child counts elements, not text nodes, so any sentence
     whose single link happens to be its only child element would match and
     be stretched to 44px mid-paragraph. Marking the onward links at the
     call site is the only way to separate "navigation that looks like a
     link" from "a link inside a sentence" — and WCAG 2.2's own target-size
     exception draws that same line. */
  .ec-onward { display: inline-flex; align-items: center; min-height: 44px; }
}

/* — language switcher active state (nav) — */
.ec-lang { appearance: none; border: 0; background: transparent; cursor: pointer;
  font: inherit; font-size: 12px; letter-spacing: 0.06em; padding: 8px 10px;
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 48px; min-height: 48px; text-decoration: none; }
/* color and font-size are re-declared at nav scope because `.nav a { color:
   inherit; font-size: 14px }` in modernist.css outranks a bare `.ec-lang`
   (0,1,1 beats 0,1,0) — the same clash already documented for the nav's
   Booking button above, and it had swallowed BOTH of this component's
   declarations silently: the inactive language codes were rendering at full
   body ink and at the nav's 14px, not the muted 12px the rule asks for, so
   the switcher's only active/inactive signal was hue. Everything else on
   .ec-lang (the box, the 48px target, letter-spacing) never clashed and
   stays there.

   70%, not the 50% this carried while it was inert: at 12px, 50% ink reads
   3.04:1 on the bar's ground, under the 4.5:1 AA floor, so making the rule
   live at 50% would have shipped a real contrast failure in place of a dead
   one. 70% is the value every other muted role in the theme was standardized
   on (see .text-muted in modernist.css) and clears 5.44:1. The active code
   still reads as clearly distinct: it is a different hue
   (--color-accent-700), not just a darker step of this one. */
.nav .ec-lang { font-size: 12px; color: color-mix(in srgb, var(--color-text) 70%, transparent); }
/* accent-700 (7.08:1), not the raw accent step, which reads 5.04:1 against
   the page ground — passing, but the thinnest margin in the theme and thinner
   than 12px text should run on (same reasoning as the body links above; the
   3.98:1 this comment used to quote was not accent's figure). */
.ec-lang[aria-current='true'],
.ec-lang[aria-current='true']:hover { color: var(--color-accent-700); }

/* — 48px touch targets on the primary nav links —
   The links themselves carry only their text's line-height; a min-height on
   the anchor (not the row) extends its own hit box without adding visible
   padding, so the compact nav look is unchanged. */
.nav-brand, .nav a:not(.ec-lang):not(.btn) {
  display: inline-flex; align-items: center; min-height: 48px;
}

/* — 48px touch targets on table action links (Listen / Book) —
   Real padding, not an invisible hit-box hack: two links can sit close
   together in adjacent rows, and an overlapping invisible zone would let a
   tap on one row's whitespace activate the next row's link. */
main table.table td a {
  display: inline-flex; align-items: center; min-height: 48px;
  padding: 4px 2px; margin: -4px -2px;
}

/* — contact form success note — */
.ec-form-status { font-size: 14px; color: var(--color-accent-700); margin: 0; }

/* — responsive collapse for the reference's fixed grid layouts —
   The desktop column template lives in the --ec-cols custom property (set
   inline per section); below 860px every such grid stacks to one column.
   Custom properties pierce the inline-style specificity problem: the class,
   not the inline attribute, owns grid-template-columns. */
.ec-cols { display: grid; grid-template-columns: var(--ec-cols, 1fr); }
@media (max-width: 860px) {
  .ec-cols { grid-template-columns: 1fr; }
}

/* — project rows (projects page) —
   The row's own hover affordance is .ec-ghost-num-wrap's (each row also
   carries that class); this is just the toggle-button version of .tag for
   the style filter, active state driven by aria-pressed so the filtering
   script only has to flip one attribute. Sized to the site's own 48px
   touch-target floor (see the nav/lang-switcher rules above) rather than
   .tag's decorative 3px/11px — these buttons are controls, not chips. */
.ec-tag-btn {
  appearance: none; border: 0; font: inherit; letter-spacing: 0.02em; cursor: pointer;
  min-height: 48px; padding: 8px 16px; font-size: 13px;
  transition: background-color .2s var(--ease-premium), color .2s var(--ease-premium), transform .15s var(--ease-premium);
}
.ec-tag-btn[aria-pressed='true'] { background: var(--color-accent-100); color: var(--color-accent-800); }
/* Same press feedback as .btn (modernist.css) — a real toggle control, not
   a decorative chip, so it earns the same tactile response on click. */
.ec-tag-btn:active { transform: scale(0.97); }

/* — visually hidden —
   Standard sr-only pattern: present for assistive tech, removed from visual
   layout without display:none (which VoiceOver/NVDA also skip). */
.ec-visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* Paired form fields (name/email, date/city) — side by side until phones. */
.ec-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 560px) {
  .ec-form-row { grid-template-columns: 1fr; }
}

/* — tables on phones: one stacked record per row —
   `display:block; overflow-x:auto` was the previous answer here, on the
   theory that a wide table would scroll inside itself rather than squeeze.
   It never actually scrolled: a table box still shrink-to-fits its
   containing block, so with nothing forcing a minimum width the columns
   just compressed. Measured on the calendar at 375px, the five-column
   upcoming table gave "Programme" 98px and "Venue" 103px — every concert
   wrapped to four or five ragged lines inside a 98px column, on the one
   page a promoter or an audience member is most likely to open on a phone.

   So each row becomes a stacked record instead. Every cell carries its
   column heading in data-label (set at the call site from the same
   localised $th array the <thead> is built from, so the labels translate
   with everything else) and renders as a label/value pair. Labels on
   *every* cell, rather than treating the first column as a kicker and the
   second as a title: that shape holds for the two calendar tables and
   home's dates, but not for media's Work/Project/Listen or events'
   Line-up/With/Draws, and a rule that quietly mislabels half the tables is
   worse than one that labels all of them plainly.

   The <thead> is hidden rather than removed — it still carries the column
   scope for assistive tech, and it comes straight back at 721px.

   Changing `display` on table elements drops the table role from the
   accessibility tree in every engine, which is why the markup re-declares
   role="table"/rowgroup/row/columnheader/cell explicitly: on desktop those
   roles are simply the implicit ones restated (no effect), and on phones
   they are what keeps the flattened rows announced as a table instead of a
   loose run of text. */
@media (max-width: 720px) {
  main table.table,
  main table.table > tbody,
  main table.table > tbody > tr,
  main table.table > tbody > tr > td { display: block; }
  /* Not display:none — that would drop the headers out of the accessibility
     tree along with the visual layout. Same sr-only pattern as
     .ec-visually-hidden. */
  main table.table > thead {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap;
  }
  /* The record itself: the rule that used to separate cells now separates
     concerts, and the accent hairline the <thead> carried on desktop opens
     the list. */
  main table.table > tbody > tr {
    padding: 14px 0;
    border-top: 1px solid var(--color-divider);
  }
  main table.table > tbody > tr:first-child { border-top: 2px solid var(--color-accent-700); }
  /* width:auto overrides the inline column percentages some views repeat on
     the <td> as well as the <th> — at 335px a leftover width:18% is a 60px
     cell. */
  /* The label is positioned out of flow in a padding gutter rather than
     placed as a grid/flex track. A grid container turns every child node
     into its own item, and these cells are not single text nodes: a
     programme title comes back from ec_linkify_projects() as a run of text
     and <a> fragments, so grid dealt each fragment its own row and a
     one-line concert title stacked four lines deep. Out of flow, the cell's
     inline content is left to wrap as ordinary text and the label simply
     hangs beside its first line. */
  main table.table > tbody > tr > td {
    position: relative;
    width: auto; padding: 0 0 0 7.5em; border: 0;
  }
  main table.table > tbody > tr > td + td { margin-top: 6px; }
  main table.table > tbody > tr > td::before {
    content: attr(data-label);
    position: absolute; left: 0; top: 0; width: 6.5em;
    font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
    /* Matches the 13px value's own 1.55 line box (≈20px), so the label sits
       on the value's first line instead of floating above it. */
    line-height: 20px;
    /* Same 70% ink as .table th, which is what this label replaces. */
    color: color-mix(in srgb, var(--color-text) 70%, transparent);
  }
  /* An optional cell (a concert with no ticket link, a download with no
     file) would otherwise print its label over empty space. */
  main table.table > tbody > tr > td:empty { display: none; }
  /* The row is no longer a table row, so the hover tint has nothing to sit
     on that reads as "this record"; the stacked block does. */
  main table.table > tbody > tr:hover { background: none; }
}

@media (max-width: 640px) {
  .table { font-size: 13px; }
}

/* — print —
   Promoters print the calendar and press pages. Keep the content; strip the
   chrome that only works on screen (nav, footer, cross-links, video play
   overlays), and let tables lay out at natural width instead of scrolling. */
@media print {
  .nav, footer, .ec-skip, .ec-crosslinks,
  .ec-vid__play, .ec-vid__time, .ec-cal-gcal, .ec-ghost-num { display: none !important; }
  body { background: #fff; color: #000; }
  main a { color: #000; }
  /* — the home hero on paper —
     Every browser prints with "Background graphics" off by default, which
     drops the hero's photograph, its ink background and the scrim. What was
     left was a tall empty box with the kicker and the H1 still set in
     --color-inverse (#f7fcff) on white: the home page's only heading printed
     as a near-invisible ghost, where before this round's hero it printed as
     ink. The picture is not worth arguing about on paper, but the H1 is.

     So: the hero collapses to its content (no fixed height, no negative
     margin pulling it under a nav that print already hides), the copy comes
     out of absolute positioning and back into the flow, the photograph is
     dropped rather than left as a grey rectangle, and the type is ink. */
  .ec-hero {
    height: auto; min-height: 0; margin-top: 0;
    background: none; overflow: visible;
  }
  .ec-hero::after { display: none; }
  .ec-hero__media, .ec-hero__sentinel { display: none; }
  .ec-hero__copy {
    position: static; transform: none; max-width: none;
    padding: 0 clamp(20px, 5vw, 72px) 12px;
  }
  .ec-hero__copy .ec-kicker { color: #000; }
  .ec-hero__title { color: #000; }
  /* The entrance animation holds its "from" state (opacity 0) through its
     delay because of fill-mode:both. A print taken before it has run leaves
     the kicker, the H1 and the lede blank on paper; nothing should animate
     onto a sheet of paper anyway. */
  .ec-rise-in { animation: none; opacity: 1; transform: none; }
  /* Every solid-fill button has the hero H1's problem for the same reason:
     .btn-primary and .btn-inverse carry a light label that only works on the
     fill behind it, and the fill is a background. Printed with background
     graphics off, "Booking" came out as #f7fcff on white paper. So on paper a
     button is an outlined label: ink text, a hairline box, no fill, no
     shadow. No !important needed anywhere here, since this file loads after
     modernist.css and the selectors are the same weight. */
  .btn {
    color: #000; background: none;
    border: 1px solid #000; box-shadow: none;
  }
  /* A printed page box is normally wider than the 720px phone breakpoint, so
     the stacked-record rules above usually never match here — but paper size
     is the user's choice, and a promoter printing the calendar should get
     real columns either way. Restores the table layout and the headings the
     phone rules hide. */
  main table.table { display: table; overflow-x: visible; }
  main table.table > thead { position: static; width: auto; height: auto; margin: 0; clip: auto; overflow: visible; }
  main table.table > tbody { display: table-row-group; }
  main table.table > tbody > tr { display: table-row; padding: 0; border-top: 0; }
  main table.table > tbody > tr > td { display: table-cell; margin: 0; }
  main table.table > tbody > tr > td::before { content: none; }
}

/* Respect the OS-level motion preference: kill hover/focus transitions
   (video poster and photo hover lift, play-button scale) rather than just
   shortening them, so nothing moves for a visitor who asked it not to. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.001ms !important;
    animation-duration: 0.001ms !important;
    /* Added alongside the hero entrance animation below: with a staggered
       animation-delay and fill-mode:both, a near-zero *duration* alone still
       leaves the element sitting invisible for the delay period before its
       instant pop-in. Zeroing the delay too means reduced-motion users see
       the content immediately, not a flash after a pause. */
    animation-delay: 0ms !important;
    scroll-behavior: auto !important;
  }
}

/* — render.php structural classes — */
.ec-site-wrapper { min-height: 100vh; background: var(--color-bg); color: var(--color-text); }
/* Nav-scoped, for the third and last case of the `.nav a` clash documented on
   .nav .btn-primary and .nav .ec-lang above: `.nav a { font-size: 14px }`
   (0,1,1) outranks modernist.css's own `.nav-brand { font-size: 18px }`
   (0,1,0), so the wordmark had been rendering at 14px — the same size as the
   section links sitting next to it, i.e. with none of the size hierarchy the
   component declares. color is re-stated here for the same reason; it
   resolved to the right value anyway (`.nav a`'s `inherit` picks up
   --color-text from the body), so this half is belt and braces, not a
   repair. */
.nav .ec-nav-brand { font-size: 18px; color: var(--color-text); }
.ec-nav-brand { text-decoration: none; color: var(--color-text); margin-right: auto; letter-spacing: -0.01em; }
.ec-lang-group { display: flex; align-items: center; gap: 4px; }
.ec-crosslinks { border-top: 2px solid var(--color-divider); }
.ec-crosslinks__container { max-width: 1180px; margin: 0 auto; padding: clamp(32px, 4vw, 48px) clamp(20px, 5vw, 72px); }
.ec-crosslinks__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px 32px; }
.ec-crosslinks__item { margin: 0; font-size: 15px; line-height: 1.5; }
.ec-footer { border-top: 2px solid var(--color-divider); }
.ec-footer__container { max-width: 1180px; margin: 0 auto; padding: clamp(40px, 5vw, 64px) clamp(20px, 5vw, 72px); }
.ec-footer__brand { font-family: var(--font-heading); font-weight: 800; font-size: clamp(28px, 4vw, 40px); line-height: 1.08; letter-spacing: -0.02em; margin: 0 0 clamp(32px, 4vw, 48px); }
.ec-footer__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 36px 32px; }
.ec-footer__list { margin: 0; padding: 0; list-style: none; font-size: 14px; line-height: 2; }
.ec-footer__hr { margin: 36px 0 20px; }
/* 70%, not 65%: the same case figcaption's own comment (modernist.css)
   describes — 65% still passes at 4.67:1, but on a thinner margin than
   everything else the muted-ink pass standardized on. 70% clears 5.44:1. */
.ec-footer__fine-print { font-size: 12px; margin: 0; color: color-mix(in srgb, var(--color-text) 70%, transparent); }

/* ── project deep links ───────────────────────────────────────────
   Ensemble names in the calendar, the /events/ line-ups table, the audio
   table and the sleeve notes link to that project's article by fragment
   (#p-<slug>). Same treatment the video anchors get: room for the sticky
   nav so the jump does not land underneath it, and an accent ring so a
   visitor sent to one of six articles can see which one. An outline rather
   than a border or padding: the article carries its own inline padding, and
   an outline is drawn outside the box so nothing reflows when it appears. */
.ec-project { scroll-margin-top: 88px; }
.ec-project:target { outline: 2px solid var(--color-accent-700); outline-offset: 6px; }

/* — cross-document view transitions —
   A default crossfade between same-origin pages (Chromium 126+ and Safari
   18.2+; Firefox simply navigates as before). Pairs with the
   Speculation-Rules prefetch: a prefetched navigation finishes well inside
   the 4-second window after which the UA skips the transition anyway. */
@view-transition { navigation: auto; }

/* Reduced motion: the global near-zero transition/animation rule above
   cannot reach the UA-generated ::view-transition pseudo-elements, so their
   animations are removed explicitly (Chrome's documented pattern). The
   navigation then cuts instantly, as it always did. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* WCAG 2.2 target size (2.5.8): the add-to-calendar link under a confirmed
   date sits on its own line in the cell, so the inline exception does not
   cover it; a 24px hit area on the 11px text satisfies the criterion without
   growing the type. Dormant while every date is TBC, live with the first
   confirmed one. */
.ec-cal-gcal { display: inline-flex; align-items: center; min-height: 24px; }

/* — questions & answers: one <details> per question, plus/minus marker — */
.ec-qa { border-top: 1px solid var(--color-divider); }
.ec-qa details { border-bottom: 1px solid var(--color-divider); }
.ec-qa summary {
  display: flex; justify-content: space-between; align-items: baseline; gap: 24px;
  padding: 18px 0; cursor: pointer; list-style: none;
  font-size: 17px; font-weight: 600; line-height: 1.35;
}
.ec-qa summary::-webkit-details-marker { display: none; }
.ec-qa summary::after {
  content: "+"; flex: none; font-size: 24px; font-weight: 400; line-height: 1;
  color: var(--color-accent-700);
}
.ec-qa details[open] summary::after { content: "\2212"; }
.ec-qa summary:focus-visible { outline: 2px solid var(--color-accent-700); outline-offset: 2px; }
.ec-qa__a { padding: 0 0 20px; }
.ec-qa__a p {
  margin: 0; font-size: 15px; line-height: 1.65;
  color: color-mix(in srgb, var(--color-text) 80%, transparent);
}

/* — privacy page —
   A reading page, not a marketing one: one column, a comfortable measure, and
   the cookie list as a definition list rather than a table, so it reflows on a
   phone instead of scrolling sideways. */
.ec-privacy__updated {
  margin: 12px 0 0; font-size: 14px;
  color: color-mix(in srgb, var(--color-text) 60%, transparent);
}
.ec-privacy__section {
  padding: clamp(28px, 4vw, 48px) 0 clamp(4px, 1vw, 12px);
  scroll-margin-top: 96px;
}
.ec-privacy__p {
  margin: 0 0 16px; max-width: 68ch;
  font-size: 16px; line-height: 1.7;
  color: color-mix(in srgb, var(--color-text) 85%, transparent);
}
.ec-privacy__table { margin: 8px 0 0; max-width: 68ch; }
.ec-privacy__row {
  display: grid; gap: 4px 20px;
  padding: 14px 0; border-top: 1px solid var(--color-divider);
}
@media (min-width: 620px) {
  .ec-privacy__row { grid-template-columns: 210px 1fr; align-items: baseline; }
}
.ec-privacy__row:last-child { border-bottom: 1px solid var(--color-divider); }
.ec-privacy__row dt { margin: 0; }
.ec-privacy__row dt code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 14px; overflow-wrap: anywhere;
  color: var(--color-accent-700);
}
.ec-privacy__row dd { margin: 0; font-size: 15px; line-height: 1.6; }
.ec-privacy__purpose { display: block; }
.ec-privacy__life {
  display: block; margin-top: 2px; font-size: 14px;
  color: color-mix(in srgb, var(--color-text) 60%, transparent);
}

/* — footer legal row — */
.ec-footer__legal {
  display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline;
  margin: 10px 0 0; font-size: 14px;
}
.ec-footer__consent {
  padding: 0; border: 0; background: none;
  font: inherit; color: inherit; cursor: pointer;
  text-decoration: underline; text-underline-offset: 3px;
}
.ec-footer__consent:focus-visible { outline: 2px solid var(--color-accent-700); outline-offset: 3px; }

/* — cookie banner —
   Bottom of the viewport on every width, over the page rather than pushing it:
   a bar that reflows the document would move the thing the visitor came to
   read. The two buttons are the same size and the refusing one comes first,
   because refusing has to be as easy as accepting. */
.ec-consent {
  position: fixed; z-index: 60;
  left: 0; right: 0; bottom: 0;
  padding: 16px clamp(16px, 4vw, 32px) max(16px, env(safe-area-inset-bottom));
  background: var(--color-bg);
  border-top: 1px solid var(--color-divider);
  box-shadow: var(--shadow-lg);
}
.ec-consent[hidden] { display: none; }
.ec-consent__inner {
  display: flex; flex-wrap: wrap; gap: 14px 28px; align-items: center;
  max-width: 1180px; margin: 0 auto;
}
.ec-consent__text {
  flex: 1 1 320px; margin: 0;
  font-size: 15px; line-height: 1.6;
  color: color-mix(in srgb, var(--color-text) 88%, transparent);
}
.ec-consent__text a { text-underline-offset: 3px; }
.ec-consent__actions { display: flex; flex: none; gap: 12px; }
/* Equal minimum width: the accepting button must not be the bigger target. */
.ec-consent__btn { min-width: 132px; justify-content: center; }
@media (max-width: 519px) {
  .ec-consent__actions { width: 100%; }
  .ec-consent__btn { flex: 1 1 0; min-width: 0; }
}
