/**
 * site.css — public site design system (Task 35).
 *
 * Thesis: the artwork is the hero, the chrome (nav, titles, UI) stays
 * small and recedes. White is always the default background; dark mode
 * is opt-in only, toggled by theme.js via [data-theme="dark"] on <html>.
 * No box-shadow, no gradient, no decorative border-radius anywhere —
 * dividers are hairlines, every transition is under 200ms, and nothing
 * animates on scroll/entrance. Hand-written, no framework.
 *
 * TWO exceptions, both requested on 2026-08-30, both on the home hero
 * and nowhere else: .hero__cta is a pill-shaped, blurred glass button,
 * and .hero__meta (the work's cartouche) has softened corners. They are
 * the only rounded surfaces on the site, and the button is the only
 * blurred one. Anything else that wants the same treatment is a new
 * decision, not a precedent to follow from here.
 */

/* ---------------------------------------------------------------------
 * Fonts — Inter, self-hosted, three weights in use: 400 (body), 600
 * (nav/emphasis) and 900 (wordmark). The variable font file carries the
 * whole 100-900 axis in one download, so the declared range is the
 * whole axis: capping it at 400-600, as this rule used to, silently
 * CLAMPED `font-weight: 900` back to 600 — the wordmark rendered
 * identically at both, byte for byte, with nothing to say so.
 * ------------------------------------------------------------------ */
@font-face {
    font-family: 'Inter';
    src: url('/assets/fonts/InterVariable.woff2') format('woff2');
    font-weight: 100 900;
    font-style: normal;
    font-display: swap;
}

/* ---------------------------------------------------------------------
 * Tokens — light is the default palette; dark only applies once
 * [data-theme="dark"] is set on <html> (theme-init.js/theme.js).
 * ------------------------------------------------------------------ */
:root {
    --bg: #fffefc;
    --ink: #111111;
    --ink-soft: #666666;
    --hairline: #e6e6e6;
    --accent: #111111;
    --error: #a13327;

    /* Form fields carry their own surface and line rather than reusing
     * --hairline: --hairline drives every divider on the site, and the
     * 3:1 non-text floor (WCAG 2.1 §1.4.11) an input border must clear
     * is not a floor a decorative divider has to clear. Measured:
     * #8a8a8a on --bg = 3.42:1, on --field-bg = 3.19:1. */
    --field-bg: #f7f6f3;
    --field-line: #8a8a8a;

    --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
        Roboto, sans-serif;

    --nav-h: 64px;
    --page-margin: clamp(1.5rem, 6vw, 7rem);
    --gutter: clamp(24px, 3vw, 60px);
    /* Prose measure, pinned in px: 65ch resolves against the font-size
     * of the ELEMENT it's set on, so a title at --fs-h1 (20px) and a
     * paragraph at --fs-body (16px) would each get a different pixel
     * width from the same `65ch`. --fs-body is itself fixed at 16px, so
     * this is that already-fixed 65ch measure (654.06px, measured) as
     * one shared constant — a rem would instead couple it to the
     * user's root font-size setting and widen it for anyone who has
     * enlarged their browser text, a readability regression for
     * exactly those users. */
    --text-col: 654px;

    /* Both wordmark axes are tokens because both are overridden per
     * band below: the header row does not wrap and does not scroll, so
     * how large the wordmark may grow is a question of measured
     * leftover width, never of taste alone. */
    --fs-wordmark: clamp(15px, 3.4vw, 27px);
    /* Every tenth here is a measured ceiling, not a preference: the
     * wordmark shares a row that never wraps and never scrolls, so
     * tracking is spent out of the same budget as the letters. 0.12em
     * is what fits once the initials are 1.25em — at 0.14em the name
     * lost 4px at 320px and 5px at 1050px, where it jumps a whole size
     * step out of the trim band. */
    --ls-wordmark: 0.12em;
    --fs-nav: 14px;
    --fs-tool: 13px;
    --fs-body: 16px;
    --fs-meta: 13px;
    --fs-h1: 20px;
    --fs-label: 12px;

    --t-fast: 140ms ease;
    --t-med: 160ms ease;
    --t-swipe: 240ms cubic-bezier(0.32, 0.72, 0, 1);

    /* Cartouches laid over an artwork — the home hero's work meta and
     * pause button, and the lightbox caption, which is the same object
     * seen in the zoom. A semi-opaque scrim, white-ish here, black-ish
     * in the dark override below — mirrors --bg's own per-theme value
     * rather than a hardcoded rgba() unrelated to it. */
    --hero-scrim: rgba(255, 254, 252, 0.85);

    /* Hero CTA glass (2026-08-30). The alpha is NOT a taste setting: it
     * is the floor at which --ink still clears 4.5:1 against the worst
     * backdrop the button can ever sit on. Measured against a fully
     * black artwork: 0.6 -> 6.6:1, and 0.8 on hover is only better.
     * Lowering it to show more of the image breaks the contrast. */
    --hero-glass: rgba(255, 254, 252, 0.6);
    --hero-glass-strong: rgba(255, 254, 252, 0.8);
    --hero-glass-line: rgba(17, 17, 17, 0.14);
}

:root[data-theme='dark'] {
    --bg: #0e0e0e;
    --ink: #ededed;
    --ink-soft: #9a9a9a;
    --hairline: #262626;
    --accent: #ededed;
    --error: #e2867e;

    /* Measured: #6b6b6b on --bg = 3.62:1, on --field-bg = 3.27:1. */
    --field-bg: #1a1a1a;
    --field-line: #6b6b6b;

    --hero-scrim: rgba(14, 14, 14, 0.75);

    /* Same floor, mirrored: dark glass is worst over a WHITE artwork,
     * where 0.62 measures 4.6:1 against --ink (#ededed). */
    --hero-glass: rgba(14, 14, 14, 0.62);
    --hero-glass-strong: rgba(14, 14, 14, 0.82);
    --hero-glass-line: rgba(237, 237, 237, 0.2);
}

/* ---------------------------------------------------------------------
 * Reset & base
 * ------------------------------------------------------------------ */
*, *::before, *::after {
    box-sizing: border-box;
}

html {
    -webkit-text-size-adjust: 100%;
}

/* Column flex + `min-height` on the body, `flex: 1 0 auto` on the main
 * below: the footer sits at the bottom of the VIEWPORT on a short page
 * (À propos was gluing it under the last paragraph, mid-screen) and
 * scrolls normally under a long one. `.site-header` is `position:
 * fixed`, so it is out of flow and never a flex item here — the column
 * holds exactly `main` and `footer`, and the lightbox overlay
 * lightbox.js appends is `position: fixed` too. The 100vh line is the
 * fallback for browsers with no `dvh` support; on mobile, `dvh` is what
 * keeps the footer off the area the URL bar covers and uncovers. */
body {
    margin: 0;
    min-height: 100vh;
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
    background: var(--bg);
    color: var(--ink);
    font-family: var(--font-sans);
    font-size: var(--fs-body);
    font-weight: 400;
    line-height: 1.6;
    transition: background-color var(--t-med), color var(--t-med);
}

img, picture {
    max-width: 100%;
    display: block;
}

ul, ol {
    margin: 0;
    padding: 0;
    list-style: none;
}

a {
    color: inherit;
}

h1, h2, h3, h4 {
    margin: 0 0 0.75rem;
    font-weight: 600;
    line-height: 1.3;
}

h1 {
    font-size: var(--fs-h1);
}

h2 {
    font-size: 15px;
}

h3 {
    font-size: var(--fs-body);
}

p {
    margin: 0 0 1rem;
}

button {
    font-family: inherit;
}

/* Visually hidden but reachable by assistive tech (home.php's <h1>). */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

:focus-visible {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        transition-duration: 0.001ms !important;
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
    }
}

/* ---------------------------------------------------------------------
 * Nav — fixed horizontal top bar, <=80px. Wordmark is the only element
 * above ~22px anywhere on the site; everything else in the chrome is
 * small and quiet on purpose.
 * ------------------------------------------------------------------ */
.site-header {
    position: fixed;
    inset: 0 0 auto 0;
    height: var(--nav-h);
    z-index: 50;
    background: var(--bg);
    border-bottom: 1px solid var(--hairline);
    transition: background-color var(--t-med), border-color var(--t-med);
}

.site-nav {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: clamp(16px, 3vw, 48px);
    height: 100%;
    padding: 0 var(--page-margin);
}

.wordmark {
    font-size: var(--fs-wordmark);
    font-weight: 600;
    /* 1, not the body's 1.6: the initials below are 1.25em, and an
     * inherited 1.6 would size the line box off THEIR font-size — 62px
     * inside a 64px header at the widest band, with nothing left for
     * the border. The wordmark is one line and needs no leading.
     *
     * The initial's INLINE BOX does then stick 5px out of this line box,
     * and `overflow: hidden` below clips that box — but not a pixel of
     * ink. Measured at the widest band with the font's real metrics
     * (canvas TextMetrics, 38.75px Inter 600): "RPM" inks from 16.1 to
     * 45.1 inside a clip running 12.1 to 50.9, so 4px of clearance
     * above and 5.8px below. The clearance is the font's ascent above
     * cap height, which capitals never use. An initial that was
     * lowercase or accented WOULD reach into it. */
    line-height: 1;
    /* Tracking is a token, not a constant, and it never reaches the
     * requested 0.25em on a narrow viewport: `white-space: nowrap`
     * below forbids wrapping, and at 320px --page-margin bottoms out
     * at its 1.5rem floor, leaving ~228px for the wordmark beside the
     * burger. Measured there: 0.12em fits, 0.25em overflows by 5px. */
    letter-spacing: var(--ls-wordmark);
    color: var(--ink);
    text-decoration: none;
    white-space: nowrap;
    /* The row's designated give, and a backstop rather than a layout
     * anyone should see: a flex item defaults to `min-width: auto` and
     * refuses to shrink below its text, so an overfull header pushed
     * its LAST item — the social icons, then the theme toggle — past
     * the viewport edge instead. Silently. Zeroing it here makes the
     * wordmark the one item that yields, so overflow costs a few
     * letters of a name that is also the <title>, the <h1> and the
     * logo link, never a control nobody can reach. With the site's own
     * 2 social links it never engages at any width: the drawer owns
     * everything below 900px, and above it the row fits with its
     * trimmed wordmark. It starts biting at 4 social links, which is
     * exactly the case that used to disappear off-screen instead. */
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* R, P and M set larger than the letters they open. In `em`, so the
 * ratio survives every band: --fs-wordmark already moves between 15px
 * and 31px, and a px value here would be a fourth number to keep in
 * step with three others. */
.wordmark__initial {
    font-size: 1.25em;
}

/* Drawer wrapper: only meaningful under 900px (media query below).
 * display: contents makes it transparent to the desktop flex row, so
 * .nav-menu and .nav-tools stay direct flex children of .site-nav. */
.nav-drawer {
    display: contents;
}

.nav-menu {
    display: flex;
    align-items: center;
    gap: clamp(16px, 2vw, 32px);
}

/* A label and its chevron are two boxes in one <li> (.nav-link plus the
 * .nav-sub-toggle button), so the row is free to break BETWEEN them —
 * and under 1050px it did, dropping "Travaux"'s chevron onto a second
 * line inside a 64px header. Gluing the <li> costs a little width,
 * which is why the band below also trims the wordmark. */
.nav-item {
    white-space: nowrap;
}

/* `white-space: nowrap` below is required, not decorative — "À propos"
 * is the first nav label containing a space, and the only compressible
 * box in the row: .nav-menu is a flex with no flex-wrap, .wordmark and
 * .social-links are already nowrap, and the other three labels
 * (Expositions/Travaux/Contact) are single words. Without it the
 * browser breaks "À propos" onto two lines inside the fixed 64px
 * header (--nav-h) before anything else even overflows — reproduced
 * in a headless browser at 768px. It lives on this BASE rule, not a
 * media query, same reasoning the .social-links comment below spells
 * out about specificity. */
.nav-link {
    font-size: var(--fs-nav);
    white-space: nowrap;
    letter-spacing: 0.02em;
    color: var(--ink);
    text-decoration: none;
    padding: 0.25rem 0;
    transition: color var(--t-fast);
}

.nav-link:hover, .nav-link:focus-visible {
    text-decoration: underline;
    text-underline-offset: 3px;
}

/* ---------------------------------------------------------------------
 * Works submenu — a chevron <button> (never an <a>, nav.js closes the
 * drawer on every real link click) toggles `.nav-sub`'s `is-open`
 * class at every width; desktop also reveals it on pure CSS hover/
 * focus, gated to real hover-capable pointers so a touch laptop/tablet
 * at desktop width still falls back to the chevron tap.
 * ------------------------------------------------------------------ */
.nav-sub-toggle {
    background: none;
    border: 0;
    padding: 0 0 0 0.35rem;
    cursor: pointer;
    color: var(--ink-soft);
    font-size: var(--fs-tool);
    line-height: 1;
    vertical-align: middle;
}

.nav-sub-toggle:hover, .nav-sub-toggle:focus-visible {
    color: var(--ink);
}

.nav-sub-toggle__icon {
    display: inline-block;
    transition: transform var(--t-fast);
}

.nav-sub-toggle[aria-expanded='true'] .nav-sub-toggle__icon {
    transform: rotate(180deg);
}

.nav-sub {
    list-style: none;
    margin: 0.4rem 0 0;
    padding: 0 0 0 1rem;
    max-height: 0;
    overflow: hidden;
    opacity: 0;
    transition: max-height var(--t-fast), opacity var(--t-fast);
}

.nav-sub.is-open {
    max-height: 240px;
    opacity: 1;
}

.nav-sub-link {
    display: block;
    padding: 0.35rem 0;
    font-size: var(--fs-tool);
    color: var(--ink-soft);
    text-decoration: none;
    transition: color var(--t-fast);
}

.nav-sub-link:hover, .nav-sub-link:focus-visible {
    color: var(--ink);
    text-decoration: underline;
    text-underline-offset: 3px;
}

/* Narrow desktop band — the first widths at which the row exists at all
 * (below 900px the drawer owns the nav) and still the tightest ones.
 * The row is wordmark + 4 labels + language + theme + up to 9 social
 * icons, none of them allowed to wrap, inside a `position: fixed`
 * header that never scrolls. Trimming the wordmark is what buys the
 * row back here. Measured with the trim removed: 70px off-screen at
 * 900px with the site's own two social links. The band no longer
 * overrides --ls-wordmark: the base value came down to the same 0.12em
 * when the initials grew, and an override that repeats its own default
 * only invites the two to drift apart. */
@media (min-width: 900px) and (max-width: 1049px) {
    :root {
        /* 2.1vw and not 2.2: at 900px, the band's first pixel, 2.2vw
         * overshot its own content box by 4px and cost the name its
         * last letter. */
        --fs-wordmark: clamp(15px, 2.1vw, 23px);
    }
}

/* The requested wordmark, at the first width where the row measurably
 * holds it. Not a threshold picked by eye: this row never wraps and
 * never scrolls, so anything past its width is painted off-screen,
 * unreachable, and nothing reports it. Measured against the site's own
 * 2 social links, then stressed by cloning them — 31px + 0.25em fits
 * from 1200px but tolerates only 5 links there, and an admin adds one
 * in two clicks. At 1366px it carries 7. Below it the wordmark still
 * grows (weight 900, 0.16em), as far as the row allows and no further.
 *
 * The cost is real and was accepted, not overlooked: between 1050 and
 * 1365 the wordmark is 29px wider than before, so the row tolerates 3
 * social links there instead of 4. */
@media (min-width: 1366px) {
    :root {
        --fs-wordmark: 31px;
        --ls-wordmark: 0.25em;
    }
}

@media (min-width: 900px) {
    .nav-item--has-sub {
        position: relative;
    }

    .nav-sub {
        position: absolute;
        top: 100%;
        left: 0;
        margin: 0;
        min-width: 160px;
        background: var(--bg);
        border: 1px solid var(--hairline);
        padding: 0.5rem 0.9rem;
        max-height: none;
        visibility: hidden;
        transition: opacity var(--t-fast), visibility var(--t-fast);
    }

    .nav-sub.is-open {
        visibility: visible;
    }
}

@media (min-width: 900px) and (hover: hover) and (pointer: fine) {
    .nav-item--has-sub:hover .nav-sub,
    .nav-item--has-sub:focus-within .nav-sub {
        opacity: 1;
        visibility: visible;
    }
}

.nav-tools {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

.lang-switch, .theme-toggle {
    font-size: var(--fs-tool);
    letter-spacing: 0.03em;
    color: var(--ink-soft);
    background: none;
    border: 0;
    padding: 0;
    cursor: pointer;
    text-decoration: none;
    transition: color var(--t-fast);
}

.lang-switch:hover, .lang-switch:focus-visible,
.theme-toggle:hover, .theme-toggle:focus-visible {
    color: var(--ink);
}

/* Shared hit-target box for the three header tools (D3: lang-switch,
 * theme-toggle and each social-link previously measured three
 * different box sizes, an irregular horizontal rhythm) — min- rather
 * than a fixed size so .lang-switch's text label isn't clipped if the
 * user enlarges their font, while still meeting WCAG 2.5.8's 24x24
 * floor. */
.lang-switch, .theme-toggle, .social-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 24px;
    min-height: 24px;
}

.theme-toggle__icon {
    display: inline-flex;
}

.theme-toggle__icon svg {
    width: 18px;
    height: 18px;
}

.nav-burger__icon {
    display: inline-flex;
}

.nav-burger__icon svg {
    width: 20px;
    height: 20px;
}

.theme-toggle__icon--sun {
    display: none;
}

:root[data-theme='dark'] .theme-toggle__icon--moon {
    display: none;
}

:root[data-theme='dark'] .theme-toggle__icon--sun {
    display: inline-flex;
}

.nav-burger {
    display: none;
    align-items: center;
    justify-content: center;
    min-width: 24px;
    min-height: 24px;
    background: none;
    border: 0;
    padding: 0;
    cursor: pointer;
    color: var(--ink);
}

/* `1 0 auto`, not `1`: the shorthand's `flex-basis: 0` would make the
 * main's own content height stop counting, so a page taller than the
 * viewport would be squashed instead of pushing the footer down. Home
 * is unchanged either way — its hero already measures
 * `100dvh - var(--nav-h)`, which fills the column on its own. */
.site-main {
    padding-top: var(--nav-h);
    flex: 1 0 auto;
}

/* Drawer: .nav-drawer is a fixed lateral panel, hidden off-flow
 * (transform + visibility) until nav.js adds `is-open`; the burger
 * only shows at this breakpoint.
 *
 * 899 and not the site's 767 boundary — the ONE nav-specific
 * breakpoint on the site, and it is a measured physical fact rather
 * than a taste: laid out as a row at 768px the header needs 727px of
 * content inside a 661px box, and what does not fit is painted past
 * the viewport edge, unreachable and silent (the language switch, the
 * theme toggle and the social icons, in that order). Nothing wraps,
 * nothing scrolls, nothing errors. Measured: the last social icon sits
 * 20px off-screen at 768px with the site's two links, 56px with three.
 * The row first clears the viewport around 900px, so that is where the
 * row starts. Page-layout rules (.show-card, .page-layout, .hero__meta)
 * keep 767 — they are not this row and never overflowed. */
@media (max-width: 899px) {
    .nav-burger {
        display: inline-flex;
    }

    .nav-drawer {
        position: fixed;
        top: var(--nav-h);
        right: 0;
        bottom: 0;
        width: min(78vw, 320px);
        display: flex;
        flex-direction: column;
        background: var(--bg);
        border-left: 1px solid var(--hairline);
        padding: 1.25rem 1.5rem 2rem;
        overflow-y: auto;
        overscroll-behavior: contain;
        transform: translateX(100%);
        visibility: hidden;
        transition: transform var(--t-med), visibility var(--t-med),
            background-color var(--t-med), border-color var(--t-med);
    }

    .nav-drawer.is-open {
        transform: translateX(0);
        visibility: visible;
    }

    .nav-tools {
        order: -1;
        flex-wrap: wrap;
        gap: 0.75rem 1rem;
        padding-bottom: 1rem;
        border-bottom: 1px solid var(--hairline);
        margin-bottom: 0.5rem;
    }

    /* .social-links normally never wraps (base rule, further down this
     * file) — this override wins by SPECIFICITY (0,2,0 beats 0,1,0),
     * not by source order, so placing it earlier in the file still
     * works. Without it, SocialLink::PLATFORMS' up to 9 icons would
     * overflow the drawer's ~233px of content width and hide behind a
     * horizontal scrollbar instead of visibly wrapping. */
    .nav-tools .social-links {
        flex-wrap: wrap;
    }

    .nav-menu {
        flex-direction: column;
        align-items: flex-start;
        gap: 0;
    }

    .nav-menu li {
        width: 100%;
    }

    .nav-item {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
    }

    .nav-link {
        display: block;
        padding: 0.6rem 0;
    }

    .nav-sub-toggle {
        display: none;
    }

    .nav-sub, .nav-sub.is-open {
        max-height: none;
    }

    .nav-sub {
        flex-basis: 100%;
        overflow: visible;
        opacity: 1;
        margin: 0 0 0.35rem;
        padding: 0.1rem 0 0.1rem 0.9rem;
        border-left: 1px solid var(--hairline);
    }
}

/* ---------------------------------------------------------------------
 * Page title — every public fragment except home.php opens with an
 * <h1> as a direct child of <main class="site-main">
 * (layout/public.php:37-39): `>` keeps this off work-detail.php's own
 * <h1> (nested inside .work-detail, kept as "meta" treatment) and
 * `:not(.sr-only)` keeps it off home's visually-hidden one. `:has()`
 * binds the title's measure to its own page's content column instead
 * of a width repeated per page; where `:has()` isn't supported the
 * title falls back to the full-bleed case (Works/404) — still aligned
 * on --page-margin, never flush against the viewport edge.
 * ------------------------------------------------------------------ */
.site-main > h1:not(.sr-only) {
    margin: 0 auto 0.75rem;
    padding: clamp(2rem, 4vw, 3rem) var(--page-margin) 0;
}

.site-main:has(> .shows-section) > h1 {
    max-width: 900px;
}

.site-main:has(> .about-body, > .contact-form) > h1 {
    max-width: var(--text-col);
}

/* A page wrapped in .page-layout (page image chosen) widens its title
 * to the layout's own measure — the two :has() rules above no longer
 * match there (their targets stop being direct children of .site-main). */
.site-main:has(> .page-layout) > h1 {
    max-width: 1400px;
}

/* 404.php renders its <h1> plus two bare <p> siblings straight under
 * .site-main, with no wrapping class (.contact-intro/
 * .contact-form__notice stay excluded — they carry their own class) —
 * keeps the not-found message and the back-home link aligned with the
 * title instead of flush against the viewport edge. */
.site-main > p:not([class]) {
    padding: 0 var(--page-margin);
}

/* ---------------------------------------------------------------------
 * Footer
 * ------------------------------------------------------------------ */
.site-footer {
    border-top: 1px solid var(--hairline);
    padding: 2rem var(--page-margin);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
}

/* Icon-anchor group, rendered once inside the nav's .nav-tools (the
 * footer stopped duplicating it, Task 11): one <li> per platform, each
 * holding a .social-link anchor built by App\Core\View::socialLink().
 * `nowrap` is required, not decorative — the fixed 64px header has no
 * room for a wrapped second row (D3); it lives on this BASE rule, not
 * a media query, so the fix applies at every width. A media query
 * would not have worked here: it adds no extra specificity over this
 * rule, so a breakpoint-scoped override placed earlier in the file
 * (as the mobile nav block above is) would still lose to this one. */
.social-links {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    gap: 0.75rem;
}

/* The <li> is a block whose line-height gives it a ~25.6px strut while
 * the anchor inside is only 18px and sits on the text baseline, not
 * centered — `flex` here lets the shared tool box's `align-items:
 * center` (above) actually center the icon instead of leaving it
 * offset toward the baseline (D3, measured 3.8px off). */
.social-links li {
    display: flex;
}

.social-link {
    display: inline-flex;
    color: var(--ink-soft);
    transition: color var(--t-fast);
}

.social-link svg {
    width: 18px;
    height: 18px;
}

.social-link:hover, .social-link:focus-visible {
    color: var(--ink);
}

/* ---------------------------------------------------------------------
 * Powered-by-ZenQuality credit + rights line — sober, ink-soft, sharing
 * .site-footer's one-line flex row: rights left, credit right.
 * ------------------------------------------------------------------ */
.zq-credit {
    display: flex;
    align-items: center;
    gap: 0.6rem;
}

.zq-credit__label {
    font-size: var(--fs-tool);
    color: var(--ink-soft);
}

.zq-credit__link {
    display: inline-flex;
    opacity: 0.85;
    transition: opacity var(--t-fast);
}

.zq-credit__link:hover, .zq-credit__link:focus-visible {
    opacity: 1;
}

.zq-logo {
    height: 22px;
    width: auto;
}

/* ZenQuality logo: two <img>s, CSS-only visibility swap — CSP-safe, no inline style. */
.zq-logo--dark {
    display: none;
}

:root[data-theme='dark'] .zq-logo--light {
    display: none;
}

:root[data-theme='dark'] .zq-logo--dark {
    display: block;
}

.site-footer__rights {
    margin: 0;
    font-size: var(--fs-tool);
    color: var(--ink-soft);
}

/* ---------------------------------------------------------------------
 * Home hero — full-bleed, one active category's featured image at a
 * time (App\Models\Category::activeWithFeatured()). Every slide is
 * server-rendered; hero.js only toggles `.is-active` to crossfade.
 * Auto-advance is paused/stopped per WCAG 2.2.2 via the .hero__toggle
 * button, and counted down by .hero__progress.
 *
 * --hero-advance is declared HERE and read by hero.js
 * (getComputedStyle), not the reverse: the countdown bar is a CSS
 * animation, and its duration cannot be written from JS without an
 * inline style, which `style-src 'self'` forbids (App\Core\Http::CSP).
 * One source of truth, in the only place both can read it.
 * ------------------------------------------------------------------ */
.hero {
    --hero-advance: 5000ms;
    /* Crops further into the artwork than `object-fit: cover` alone,
     * requested 2026-08-30. 1.18 on a 1920px viewport asks 2266px of
     * the `large` variant, which is 2400px wide — still no upscaling. */
    --hero-zoom: 1.18;
    position: relative;
    width: 100%;
    height: 100vh; /* fallback for browsers without dvh support */
    height: calc(100dvh - var(--nav-h));
    min-height: 420px;
    overflow: hidden;
    background: var(--bg);
}

.hero__slide {
    position: absolute;
    inset: 0;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--t-med);
}

.hero__slide.is-active {
    opacity: 1;
    pointer-events: auto;
}

.hero__slide picture,
.hero__slide img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* The zoom rides on the <img>, never on .hero__slide: scaling the slide
 * would scale the overlay with it, blurring the button's text and
 * pushing it off the viewport. .hero's `overflow: hidden` does the
 * clipping. */
.hero__slide img {
    transform: scale(var(--hero-zoom));
}

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

/* Two rows — free space, then the meta cartouche — rather than two
 * corners anchored independently. The button centers in row 1, so it
 * can never land on the cartouche when it moves to the bottom on
 * mobile, whatever that cartouche's height. `pointer-events: none`
 * here with `auto` on the children is what makes the whole image
 * hoverable while only the button is clickable (the requested
 * behaviour): without it this transparent box would swallow the hover
 * meant for the slide underneath. */
.hero__overlay {
    position: absolute;
    inset: 0;
    display: grid;
    grid-template-rows: 1fr auto;
    justify-items: center;
    padding: clamp(12px, 3vw, 32px);
    pointer-events: none;
}

.hero__cta {
    align-self: end;
    margin-bottom: clamp(12px, 3vw, 24px);
    display: inline-flex;
    align-items: center;
    gap: 0.5em;
    max-width: 100%;
    padding: clamp(0.85rem, 1.6vw, 1.15rem) clamp(1.5rem, 3vw, 2.25rem);
    /* The file's opening thesis rules out decorative border-radius; the
     * pill shape is an explicit 2026-08-30 request, recorded there as
     * the one exception rather than contradicted in silence. */
    border-radius: 999px;
    border: 1px solid var(--hero-glass-line);
    background: var(--hero-glass);
    -webkit-backdrop-filter: blur(18px) saturate(140%);
    backdrop-filter: blur(18px) saturate(140%);
    color: var(--ink);
    font-size: clamp(16px, 2.2vw, 20px);
    font-weight: 600;
    line-height: 1.2;
    text-decoration: none;
    pointer-events: auto;
    transition: background-color var(--t-med), transform var(--t-med);
}

.hero__cta-arrow {
    font-weight: 400;
}

/* No blur available -> the alpha alone has to carry the contrast, so it
 * rises to the cartouche scrim's. The artwork stops showing through;
 * unreadable white-on-white never happens. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .hero__cta {
        background: var(--hero-scrim);
    }
}

/* The requested affordance: hovering ANYWHERE on the artwork lights the
 * button up, while the click target stays the button alone. Guarded by
 * `hover: hover` so a touch device never sticks in the hovered state
 * after a tap. */
@media (hover: hover) {
    .hero__slide:hover .hero__cta {
        background: var(--hero-glass-strong);
        transform: scale(1.03);
    }
}

.hero__cta:hover, .hero__cta:focus-visible {
    background: var(--hero-glass-strong);
}

/* The colour change stays (it is the affordance); only the scale goes. */
@media (prefers-reduced-motion: reduce) and (hover: hover) {
    .hero__slide:hover .hero__cta {
        transform: none;
    }
}

.hero__meta {
    /* The overlay centres its items for the button's sake; the cartouche
     * opts back out to the corner it has always held. Centred under a
     * centred button it would read as one stacked block. */
    justify-self: end;
    max-width: min(90vw, 380px);
    padding: 1em 1.2em;
    /* Softened, not a pill: the cartouche holds several lines of text,
     * and .hero__cta's 999px would bow its short edges inward. Sized to
     * read as a rounded corner at the cartouche's scale, no more. */
    border-radius: 10px;
    background: var(--hero-scrim);
    color: var(--ink);
    pointer-events: auto;
}

/* Below the site's mobile boundary (767: the site's existing boundary,
 * never a new one) the artwork keeps the screen to itself: title,
 * description and year go, and the button is the only thing left over
 * the image. Requested 2026-08-30 — on a phone that cartouche is a
 * third of the visible height, and the work stops being the subject.
 *
 * `display: none` and not a template condition on purpose: the text
 * stays in the HTML every client is served, so it stays indexable and
 * a screen reader still reaches it. Nothing about the work is withheld,
 * only its painted box. */
@media (max-width: 767px) {
    .hero__meta {
        display: none;
    }
}

.hero__title {
    margin: 0 0 0.3em;
    font-weight: 600;
}

.hero__description {
    margin: 0 0 0.3em;
    color: var(--ink-soft);
    font-size: var(--fs-meta);
}

.hero__year {
    margin: 0;
    color: var(--ink-soft);
    font-size: var(--fs-meta);
}

.hero__toggle {
    position: absolute;
    left: clamp(8px, 2vw, 24px);
    bottom: clamp(8px, 2vw, 24px);
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--hero-scrim);
    color: var(--ink);
    border: 0;
    font-size: 14px;
    line-height: 1;
    cursor: pointer;
    opacity: 0.7;
    transition: opacity var(--t-fast);
}

.hero__toggle:hover, .hero__toggle:focus-visible {
    opacity: 1;
}

/* Countdown to the next slide. hero.js owns `is-running` only — it
 * never writes a duration or a width, which would take an inline style
 * the CSP refuses. `forwards` holds the bar full at the end of a cycle
 * instead of snapping back to empty a frame before the slide changes. */
.hero__progress {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 3px;
    /* The scrim, not a translucent ink: this strip sits on an arbitrary
     * artwork, and an --ink fill at any alpha vanishes on an artwork
     * that happens to be its own colour. The scrim gives the fill a
     * known background to read against, whatever the image behind —
     * same reasoning as the cartouches, on 3 pixels. */
    background: var(--hero-scrim);
}

.hero__progress-bar {
    display: block;
    width: 100%;
    height: 100%;
    background: var(--ink);
    transform: scaleX(0);
    transform-origin: left center;
}

.hero__progress-bar.is-running {
    animation: hero-progress var(--hero-advance) linear forwards;
}

/* Every suspension cause (manual pause, hidden tab, reduced motion at
 * load) lands on this one class — see hero.js's syncTimer(). */
.hero.is-paused .hero__progress-bar {
    animation-play-state: paused;
}

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

/* ---------------------------------------------------------------------
 * Works grid — CSS multi-column masonry, zero JS: tiles pack into each
 * column top to bottom, so a shorter tile lets the one below it ride
 * up instead of a row-aligned grid leaving dead vertical space under
 * its tallest neighbor. Accepted tradeoff: reading order runs column
 * by column rather than row by row, and there is no more double-width
 * tile (`grid-column: span N` has no multi-column equivalent). img
 * carries its own width/height from PHP; height stays auto here, never
 * forced square.
 * ------------------------------------------------------------------ */
.works-grid {
    columns: 1;
    column-gap: var(--gutter);
    padding: 2.5rem var(--page-margin) 4rem;
}

@media (min-width: 640px) {
    .works-grid {
        columns: 2;
    }
}

@media (min-width: 1024px) {
    .works-grid {
        columns: 3;
    }
}

@media (min-width: 1600px) {
    .works-grid {
        columns: 4;
    }
}

/* Never `display` here — works-filter.js:27 drives visibility through
 * the `hidden` attribute, and any author-origin `display` on this
 * selector silently overrides it (LRN-002). `break-inside` (+ the
 * -webkit prefix some engines still need) is the multi-column-specific
 * guard: without it a tile can be split across two columns, a failure
 * mode that is silent and invisible to text-only tests. The bottom
 * padding re-attaches each caption to its own image now that tiles
 * stack with no gap between them. */
.works-grid__item {
    break-inside: avoid;
    -webkit-column-break-inside: avoid;
    padding-bottom: 3rem;
}

.works-grid__link {
    display: block;
    text-decoration: none;
    color: var(--ink);
}

.works-grid__link picture,
.works-grid__link img {
    width: 100%;
    height: auto;
}

/* The badge centres on the IMAGE, not the card: .works-grid__link also
 * wraps the title/desc/date spans. The wrapper is what establishes the
 * containing block — the badge is its child, not picture's sibling.
 * .works-grid__item still declares no author `display` (BDR-014,
 * CssInvariantTest:81 pins it); this touches the wrapper only. */
.works-grid__media {
    position: relative;
    display: block;
}

.works-grid__play {
    position: absolute;
    inset: 0;
    margin: auto;
    width: 56px;
    height: 56px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: #fff;
    background: rgba(0, 0, 0, 0.45);
    border-radius: 50%;
    pointer-events: none;
}

.works-grid__play svg {
    width: 24px;
    height: 24px;
}

/* ---------------------------------------------------------------------
 * Recall flash — lightbox.js drops `is-recalled` on the trigger it was
 * closed on and takes it off again on its own timer. The outline lives
 * in the base state and the keyframes only pulse it: the global
 * prefers-reduced-motion rule above cuts every animation to 0.001ms, so
 * a signal that existed only in the keyframes would be invisible to
 * precisely the readers who asked for less movement. Outline, not a
 * halo: box-shadow is forbidden site-wide (file header), and the ring
 * reuses the focus vocabulary the site already speaks. It hugs the IMG
 * rather than the media wrapper for two reasons — the wrapper's token is
 * pinned to a single rule block by CssInvariantTest, and the img is what
 * the request calls the miniature. 1.2s so the pulse is still running
 * when a smooth scroll finishes delivering the thumbnail.
 * ------------------------------------------------------------------ */
.works-grid__link.is-recalled img {
    outline: 2px solid var(--accent);
    outline-offset: 4px;
    animation: recall-flash 1.2s ease-out;
}

@keyframes recall-flash {
    0%, 50%, 100% { outline-color: var(--accent); }
    25%, 75% { outline-color: transparent; }
}

.works-grid__title,
.works-grid__desc,
.works-grid__date {
    display: block;
    font-size: var(--fs-meta);
    line-height: 1.5;
}

.works-grid__title {
    margin-top: 0.75rem;
    color: var(--ink);
}

.works-grid__desc,
.works-grid__date {
    color: var(--ink-soft);
}

/* No-metadata placeholders ("Sans titre" / "Untitled"…) — visually
 * distinguishable from real content by reusing --ink-soft (already the
 * grid's own muted tone) plus italic; no new custom property. */
.works-grid__title--placeholder,
.works-grid__desc--placeholder,
.works-grid__date--placeholder,
.work-detail__title--placeholder,
.work-detail__desc--placeholder,
.work-detail__date--placeholder {
    color: var(--ink-soft);
    font-style: italic;
}

.works-filter {
    display: flex;
    flex-wrap: wrap;
    gap: 1.5rem;
    padding: 2rem var(--page-margin) 0;
}

.works-filter__btn {
    background: none;
    border: 0;
    padding: 0.25rem 0;
    font-size: var(--fs-tool);
    color: var(--ink-soft);
    cursor: pointer;
    transition: color var(--t-fast);
}

.works-filter__btn:hover, .works-filter__btn:focus-visible {
    color: var(--ink);
}

.works-filter__btn[aria-pressed='true'] {
    color: var(--ink);
    text-decoration: underline;
    text-underline-offset: 3px;
}

/* ---------------------------------------------------------------------
 * Work detail — single column, generous margins, the artwork large.
 * Title/description/date are treated as small meta, not a headline:
 * the painting stays the only large thing on the page.
 * ------------------------------------------------------------------ */
.work-detail {
    padding: 2.5rem var(--page-margin) 3rem;
    max-width: 1400px;
    margin: 0 auto;
}

.work-detail__media {
    display: block;
    width: 100%;
    background: none;
    border: 0;
    padding: 0;
    cursor: zoom-in;
}

.work-detail__media picture,
.work-detail__media img {
    width: 100%;
    height: auto;
}

.work-detail__title,
.work-detail__desc,
.work-detail__date {
    font-size: var(--fs-meta);
    font-weight: 400;
    line-height: 1.5;
    max-width: 70ch;
}

.work-detail__title {
    margin-top: 1.5rem;
    font-weight: 600;
}

.work-detail__desc,
.work-detail__date {
    color: var(--ink-soft);
}

.work-detail__nav {
    display: flex;
    justify-content: space-between;
    gap: 1.5rem;
    margin-top: 2rem;
    padding: 1.5rem var(--page-margin) 0;
    border-top: 1px solid var(--hairline);
}

.work-detail__nav-link {
    font-size: var(--fs-meta);
    color: var(--ink-soft);
    text-decoration: none;
    transition: color var(--t-fast);
}

.work-detail__nav-link:hover, .work-detail__nav-link:focus-visible {
    color: var(--ink);
}

.work-detail__nav-link--next {
    margin-left: auto;
    text-align: right;
}

/* ---------------------------------------------------------------------
 * Shows — upcoming/past sections, hairline divider, small section label,
 * and the upcoming section's closing follow note.
 * ------------------------------------------------------------------ */
.shows-section {
    padding: 0 var(--page-margin);
    max-width: 900px;
    margin: 0 auto;
}

.shows-section h2 {
    font-size: var(--fs-label);
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--ink-soft);
    margin: 3rem 0 1.5rem;
    padding-bottom: 0.75rem;
    border-bottom: 1px solid var(--hairline);
}

/* Grid, not flex, for one reason: a card with NO photo still has to be
 * placed in the second column so its text lines up with the text of an
 * illustrated card — flex can only push it there with a filler element
 * in the markup. */
.show-card {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
    padding: 1.5rem 0 2rem;
    border-bottom: 1px solid var(--hairline);
}

/* :last-of-type, not :last-child — .shows-notice is the last child of
 * the upcoming section, and it is the last CARD that must lose its rule. */
.show-card:last-of-type {
    border-bottom: 0;
}

/* Homogeneity: as soon as ONE show in a section carries a photo, every
 * card in that section reserves the photo column, so illustrated and
 * bare shows share a single text edge instead of alternating between
 * two indents. Scoped per section (:has(), already the site's idiom —
 * see .site-main:has(> .shows-section)) and not per page: a section
 * where nobody has a photo would otherwise carry 220px of dead gutter. */
.shows-section:has(.show-card__photo) .show-card {
    grid-template-columns: 220px minmax(0, 1fr);
}

.shows-section:has(.show-card__photo) .show-card__body {
    grid-column: 2;
}

/* The show's own photo (2026-08-29, `show`.image_id) — a fixed column
 * beside the text, not the .page-layout sticky column above (that one
 * illustrates the whole page, this one illustrates one exposition). */
.show-card__photo {
    grid-column: 1;
}

.show-card__photo picture,
.show-card__photo img {
    display: block;
    width: 100%;
    height: auto;
}

.show-card__body {
    flex: 1 1 auto;
    min-width: 0;
}

.show-card__title {
    font-size: var(--fs-body);
}

.show-card__kind,
.show-card__venue,
.show-card__dates {
    font-size: var(--fs-meta);
    color: var(--ink-soft);
}

/* The venue name IS the link when the show carries one, and so is the
 * word Instagram in the follow note. Both keep the soft grey of their
 * surrounding meta text and the browser's own underline (the base `a`
 * rule only resets colour); hover darkens them to --ink, the same
 * "meta text wakes up" idiom as .works-filter__btn. */
.show-card__venue a,
.shows-notice a {
    text-underline-offset: 3px;
    transition: color var(--t-fast);
}

.show-card__venue a:hover,
.show-card__venue a:focus-visible,
.shows-notice a:hover,
.shows-notice a:focus-visible {
    color: var(--ink);
}

.show-card__text {
    font-size: var(--fs-body);
    max-width: 70ch;
    margin-top: 0.75rem;
}

/* The upcoming section's closing note. Meta register — smaller and
 * softer than a card — so it reads as a word from the site rather than
 * as one more exposition. Its first line, the empty state, takes --ink
 * because on an empty section it is the whole page's answer. */
.shows-notice {
    font-size: var(--fs-meta);
    color: var(--ink-soft);
    max-width: 70ch;
    margin: 1.5rem 0 0;
}

.shows-notice__empty {
    display: block;
    color: var(--ink);
    margin-bottom: 0.25rem;
}

/* 767: the site's existing mobile boundary (site.css uses 768/767
 * everywhere — never invent a new breakpoint). */
@media (max-width: 767px) {
    /* One column: there is no reserved gutter to align against on a
     * phone, each photo simply stacks above its own text. */
    .shows-section:has(.show-card__photo) .show-card {
        grid-template-columns: minmax(0, 1fr);
    }

    .shows-section:has(.show-card__photo) .show-card__body {
        grid-column: 1;
    }

    .show-card__photo {
        width: 100%;
        max-width: 320px;
    }
}

/* ---------------------------------------------------------------------
 * About — bio in a comfortable measure.
 * ------------------------------------------------------------------ */
.about-body {
    padding: 0 var(--page-margin) 3rem;
    max-width: var(--text-col);
    margin: 0 auto;
}

.about-body p {
    font-size: var(--fs-body);
    line-height: 1.6;
}

/* ---------------------------------------------------------------------
 * Contact — clean fields; the honeypot MUST stay invisible to sighted
 * users (it is already out of tab order/autofill in the template).
 * ------------------------------------------------------------------ */
.contact-intro {
    padding: 0 var(--page-margin);
    max-width: var(--text-col);
    margin: 0 auto 2rem;
}

.contact-form {
    padding: 0 var(--page-margin) 3rem;
    max-width: var(--text-col);
    margin: 0 auto;
}

.contact-form__field {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    margin-bottom: 1.5rem;
}

.contact-form__field label {
    font-size: var(--fs-meta);
    color: var(--ink-soft);
}

.contact-form__field input,
.contact-form__field textarea {
    font: inherit;
    font-size: var(--fs-body);
    color: var(--ink);
    background: var(--field-bg);
    border: 0;
    border-bottom: 1px solid var(--field-line);
    padding: 0.5rem 0.6rem;
    resize: vertical;
    transition: border-color var(--t-fast);
}

.contact-form__field input:focus,
.contact-form__field textarea:focus {
    border-bottom-color: var(--ink);
}

.contact-form__error {
    font-size: var(--fs-meta);
    color: var(--error);
    margin: 0;
}

.contact-form__notice {
    font-size: var(--fs-body);
    padding: 0.75rem 0;
    border-bottom: 1px solid var(--hairline);
    margin-bottom: 1.5rem;
}

.contact-form button[type='submit'] {
    font-size: var(--fs-nav);
    color: var(--ink);
    background: transparent;
    border: 1px solid var(--ink);
    padding: 0.6rem 1.75rem;
    cursor: pointer;
    transition: background-color var(--t-fast), color var(--t-fast);
}

.contact-form button[type='submit']:hover,
.contact-form button[type='submit']:focus-visible {
    background: var(--ink);
    color: var(--bg);
}

/* Honeypot: CSS-hidden, never visible/tabbable to a sighted user. Any
 * value submitted in it is treated as spam server-side. */
.contact-form__hp {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
}

/* ---------------------------------------------------------------------
 * Lightbox — full-viewport overlay built by lightbox.js. Backdrop is a
 * solid --bg (not a translucent scrim); the whole overlay cross-fades
 * in/out via its own opacity. A 3-slide `.lightbox__track` (prev/
 * current/next, always rendered) is dragged 1:1 under the finger during
 * a touch gesture and only the release settles it, via `.is-settling`
 * — the base track rule declares NO transition on purpose (a dragged
 * image must never lag the finger). Close (top-right) and prev/next
 * controls are real <button>s wired in lightbox.js, layered above the
 * track by z-index — discreet ink glyphs, no fill/border/radius, in
 * keeping with the receding chrome. Keyboard (arrows/Esc), swipe, and
 * backdrop-click all still work alongside them.
 * ------------------------------------------------------------------ */
.lightbox {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--bg);
    opacity: 0;
    visibility: hidden;
    overflow: hidden;                 /* clips the ±1 slides */
    touch-action: pan-y pinch-zoom;   /* default; syncTouchAction() overrides
                                         it to `auto` while pinch-zoomed */
    transition: opacity var(--t-med), visibility var(--t-med);
}

.lightbox[hidden] {
    display: none;
}

.lightbox.is-open {
    opacity: 1;
    visibility: visible;
}

.lightbox__track {
    position: absolute;
    inset: 0;
    display: flex;
    transform: translate3d(-100%, 0, 0);
}

/* The transition lives HERE and never on the base rule: during the drag the
 * image must track the finger 1:1, and a transition on .lightbox__track makes
 * it lag. Do not "tidy" this into the block above. */
.lightbox__track.is-settling {
    transition: transform var(--t-swipe);
}

/* Composited only while actually moving — the track spans three viewports and
 * can hold three multi-megapixel images; a permanent promotion is a real cost
 * on the phones this feature targets. */
.lightbox__track.is-dragging,
.lightbox__track.is-settling {
    will-change: transform;
}

.lightbox__slide {
    position: relative;               /* anchors the loading ring */
    flex: 0 0 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--bg);            /* an unloaded neighbour is not a hole */
}

.lightbox__slide img {
    transition: opacity var(--t-fast);
}

/* Delayed HARD CUT, not a fade: the 150ms delay lets a cached load resolve
 * without ever blanking the slide, but fading out would then keep the
 * PREVIOUS artwork on screen for another 140ms on top of the delay — the
 * reported symptom. Hiding is instant once the delay lapses; only the
 * reveal (base rule above) fades, and it fades in the correct image. */
.lightbox__slide.is-loading img {
    opacity: 0;
    transition: opacity 0s 150ms;
}

.lightbox__slide::after {
    content: '';
    position: absolute;
    inset: 0;                         /* with margin:auto, centres without */
    margin: auto;                     /* relying on the flex static position */
    width: 32px;
    height: 32px;
    border: 2px solid var(--ink-soft);
    border-top-color: transparent;
    border-radius: 50%;
    opacity: 0;
    pointer-events: none;             /* else it hit-tests and closes */
    transition: opacity var(--t-fast) 150ms;
}

.lightbox__slide.is-loading::after {
    opacity: 1;
    animation: lightbox-spin 700ms linear infinite;
}

@keyframes lightbox-spin {
    to { transform: rotate(360deg); }
}

/* The global reduced-motion rule freezes the spin, leaving a ring with a
 * gap that reads as broken; close the ring so it degrades to a static,
 * complete circle. aria-busy carries the state for AT either way. */
@media (prefers-reduced-motion: reduce) {
    .lightbox__slide.is-loading::after {
        border-top-color: var(--ink-soft);
    }
}

.lightbox__img[hidden] {              /* beats `img { display: block }` below */
    display: none;
}

.lightbox__close,
.lightbox__nav {
    position: absolute;
    z-index: 1;
    background: none;
    border: 0;
    color: var(--ink);
    cursor: pointer;
    line-height: 1;
    padding: 12px;
    transition: opacity var(--t-fast);
}

.lightbox__close {
    top: clamp(8px, 2vw, 24px);
    right: clamp(8px, 2vw, 24px);
    font-size: 26px;
}

.lightbox__nav {
    top: 50%;
    transform: translateY(-50%);
    font-size: 34px;
    color: var(--ink-soft);
}

.lightbox__nav--prev {
    left: clamp(4px, 2vw, 24px);
}

.lightbox__nav--next {
    right: clamp(4px, 2vw, 24px);
}

.lightbox__close:hover,
.lightbox__nav:hover {
    opacity: 0.6;
}

.lightbox__img {
    max-width: min(92vw, 1400px);
    max-height: 88vh;
    width: auto;
    height: auto;
    object-fit: contain;
}

/* ---------------------------------------------------------------------
 * Caption — the work's title, description and year over the artwork,
 * bottom right, built by lightbox.js. Deliberately the same cartouche
 * as .hero__meta (--hero-scrim, 10px corner, meta type in --ink-soft):
 * the home carousel and the zoom show the same object, and a second
 * treatment would read as a second kind of thing. Its arrow folds the
 * text away for an unobstructed image — the collapse is .nav-sub's
 * max-height/opacity idiom, the flip .nav-sub-toggle__icon's, both
 * already in this file rather than a third way of doing it.
 * ------------------------------------------------------------------ */
.lightbox__caption {
    position: absolute;
    z-index: 1;
    right: clamp(8px, 2vw, 24px);
    bottom: clamp(8px, 2vw, 24px);
    max-width: min(90vw, 380px);
    display: flex;
    flex-direction: column;
    align-items: flex-end;      /* the arrow keeps the corner; the body
                                   below it hugs its own longest line */
    gap: 0.35em;
    padding: 0.7em 0.9em;
    border-radius: 10px;
    background: var(--hero-scrim);
    color: var(--ink);
}

/* Sets `display`, so it needs its paired [hidden] rule — a work with no
 * metadata at all shows no box (LRN-002's shape, as .lightbox__play). */
.lightbox__caption[hidden] {
    display: none;
}

.lightbox__caption-toggle {
    padding: 2px 4px;
    border: 0;
    background: none;
    color: var(--ink-soft);
    font-size: 14px;
    line-height: 1;
    cursor: pointer;
    transition: color var(--t-fast), transform var(--t-fast);
}

.lightbox__caption-toggle:hover,
.lightbox__caption-toggle:focus-visible {
    color: var(--ink);
}

/* Folded, the glyph points back up: the only thing left on screen, and
 * it has to read as "reopen" without a label being visible. */
.lightbox__caption.is-folded .lightbox__caption-toggle {
    transform: rotate(180deg);
}

.lightbox__caption-body {
    display: flex;
    flex-direction: column;
    gap: 0.3em;                 /* not per-line margins: `gap` skips a
                                   hidden line, a trailing margin doesn't */
    max-height: 50vh;
    overflow: hidden;
    opacity: 1;
    transition: max-height var(--t-fast), opacity var(--t-fast);
}

.lightbox__caption.is-folded .lightbox__caption-body {
    max-height: 0;
    opacity: 0;
}

.lightbox__caption-title {
    margin: 0;
    font-weight: 600;
}

.lightbox__caption-description,
.lightbox__caption-date {
    margin: 0;
    color: var(--ink-soft);
    font-size: var(--fs-meta);
}

/* ---------------------------------------------------------------------
 * Video slides — a play affordance over the poster, and the provider
 * iframe that replaces it once pressed. Both sit inside
 * `.lightbox__slide`, which already carries `position: relative` for
 * the loading ring.
 * ------------------------------------------------------------------ */

/* Sets `display`, so it NEEDS the paired [hidden] rule below: an
 * author-origin display defeats the hidden attribute outright, and the
 * button would then show on every image slide (LRN-002, BLK-001's exact
 * shape — the same reason .lightbox__img[hidden] exists above). */
.lightbox__play {
    position: absolute;
    inset: 0;
    margin: auto;
    width: 72px;
    height: 72px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    cursor: pointer;
    transition: background-color var(--t-fast);
}

.lightbox__play[hidden] {
    display: none;
}

.lightbox__play:hover, .lightbox__play:focus-visible {
    background: rgba(0, 0, 0, 0.75);
}

/* The glyph is drawn, not fetched: this button is built by lightbox.js,
 * which cannot call View::readIconFile() the way the grid badge does.
 * A border triangle needs no asset, no innerHTML and no second copy of
 * play.svg to drift from the first. margin-left is optical centring —
 * a triangle's visual mass sits left of its bounding box. */
.lightbox__play::before {
    content: '';
    width: 0;
    height: 0;
    margin-left: 5px;
    border-style: solid;
    border-width: 13px 0 13px 21px;
    border-color: transparent transparent transparent currentColor;
}

/* Absolutely positioned, not a flex item: `.lightbox__slide` is
 * `display: flex`, so an in-flow iframe would lay out BESIDE a poster
 * that reappears rather than behind it. */
.lightbox__embed {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
}

/* ---------------------------------------------------------------------
 * Page layout — optional page-level image (content.image_id) left of
 * the page content (2026-08-04): image sticky under the fixed nav on
 * desktop, stacked above the content on mobile. Pages render this
 * wrapper ONLY when an image is chosen — no empty column otherwise.
 * This section stays at the END of the file AND the child reset below
 * carries (0,2,0) specificity — belt and braces, either alone beats
 * the (0,1,0) per-page measures (.about-body etc.) it must override.
 * ------------------------------------------------------------------ */
.page-layout {
    display: grid;
    grid-template-columns: 4fr 5fr;
    gap: clamp(2rem, 4vw, 3.5rem);
    align-items: start;
    max-width: 1400px;
    margin: 0 auto;
    padding: 0 var(--page-margin);
}

/* About only (2026-08-30): prose and image share a middle instead of
 * both hanging from the top. Scoped by a modifier class rather than a
 * `:has(.about-body)` — the wrapper is written by hand in each page
 * fragment, so the page that wants the centering says so itself, and
 * Contact keeps its top alignment with no negated selector.
 *
 * `align-items` alone, NOT `place-items`: the column axis is the only
 * one being changed here, and the inline axis must keep its `stretch`
 * default or both columns would shrink to their content width and stop
 * filling the grid track. */
.page-layout--centered {
    align-items: center;
}

/* Sticky survives the centering: the sticky element's containing block
 * stays its grid AREA, which spans the full row either way — centering
 * only moves the element inside that area. On a page short enough not
 * to scroll, sticky never engages and the centering is all that shows. */
.page-layout__media {
    position: sticky;
    top: calc(var(--nav-h) + 1rem);
}

.page-layout__media picture,
.page-layout__media img {
    display: block;
    width: 100%;
    height: auto;
}

/* Inside the wrapper, the page's own blocks give up their standalone
 * centered measures — the layout column is the measure now. Generic
 * child selector, not a per-page class list: any page adopting the
 * layout gets it without editing this rule (challenge simplicity#5).
 * TWO class tokens on purpose: (0,2,0) outranks every (0,1,0)
 * per-page measure regardless of source order (confirmation BLOCKER). */
.page-layout .page-layout__body > * {
    padding-left: 0;
    padding-right: 0;
    max-width: none;
    margin-left: 0;
    margin-right: 0;
}

/* 767: the site's existing mobile boundary (site.css uses 768/767
 * everywhere — never invent a new breakpoint, challenge correctness#3). */
@media (max-width: 767px) {
    .page-layout {
        display: block;
    }

    .page-layout__media {
        position: static;
        margin-bottom: 2rem;
    }
}
