/* ─────────────────────────────────────────────
   Diffusion — diffusiondj.com

   One stylesheet, read top to bottom in page order: tokens, fonts, base,
   nav, hero and wordmark, section shell, sets, photos, dialogs, lightbox,
   gigs, booking, footer, reduced motion. Each component carries its own
   tablet and phone rules directly after its desktop rules, so it can be read
   in one place. One ordering rule is load-bearing and is marked where it
   applies: a selector's max-width blocks stay in descending order.

   Colour comes only from the six tokens in :root. A tint is color-mix() of a
   token with transparent, never a new hex; tools/check.py counts the hexes.
   ───────────────────────────────────────────── */

/* ── tokens ── */
:root {
    --void:   #08070C;
    --panel:  #12111A;
    --line:   #1D1B26;
    --bone:   #E8E4DC;
    --grey:   #7E7A88;
    --violet: #6E64FF;

    --gut: 40px;

    /* Content measure. Without this the section headings span the full viewport
       on a wide monitor while the grid sits in a narrow column, which reads
       lopsided. --edge centers everything on one measure and collapses back to
       the plain gutter once the viewport is narrower than --page, so nothing
       changes on laptops and phones. It resolves against the element it is used
       on, so it belongs on full-width blocks only. */
    --page: 1480px;
    --edge: max(var(--gut), calc((100% - var(--page)) / 2));
    --mono: 'JetBrains Mono', ui-monospace, monospace;

    /* Hover colour changes, and the dialog fade. js/dialog.js waits on the
       running transitions rather than on a copy of --fade, so this is the only
       place either lives. */
    --hover: .18s;
    --fade: .26s;
}

@media (max-width: 600px) {
    :root { --gut: 20px; }
}

/* ── fonts ──
   Self-hosted latin subsets of Archivo (weight 400-900, width 62-125%) and
   JetBrains Mono, both SIL Open Font License, copied from Google Fonts' woff2
   builds. Same origin means the wordmark face arrives over the connection the
   page already has open, with no DNS or TLS to a second host in front of the
   entrance; that matters because the entrance is a one-shot animation whose
   clock starts at document load. The page has no text outside the latin
   range, so no other subset is shipped. JetBrains Mono's 400 and 500 come from
   one variable file, so one rule declares the range. No unicode-range: with one
   file per face there is nothing to defer. The HTML preloads both files so the
   real faces are present at first paint. */
@font-face {
    font-family: 'Archivo';
    font-style: normal;
    font-weight: 400 900;
    font-stretch: 62% 125%;
    font-display: optional;
    src: url(../fonts/archivo-latin.woff2) format('woff2');
}
@font-face {
    font-family: 'JetBrains Mono';
    font-style: normal;
    font-weight: 400 500;
    font-display: swap;
    src: url(../fonts/jetbrains-mono-latin.woff2) format('woff2');
}

/* ── base ── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
    background: var(--void);
    color: var(--bone);
    font-family: 'Archivo', system-ui, sans-serif;
    font-size: 16px;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
}

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

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

/* ── nav ── */
.nav {
    position: sticky;
    top: 0;
    z-index: 10;
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 20px;
    /* Gutter, not --edge. The nav is a full-bleed bar: its background and border
       already span the viewport, so insetting its contents to the content measure
       left the wordmark and links floating well inside the bar on wide screens. */
    padding: 20px var(--gut);
    background: color-mix(in srgb, var(--void) 86%, transparent);
    backdrop-filter: blur(14px);
    border-bottom: 1px solid var(--line);
}

.nav-mark {
    font-weight: 700;
    font-stretch: 108%;
    font-size: 15px;
    letter-spacing: .3em;
    color: var(--bone);
    white-space: nowrap;
}

.nav-links { display: flex; gap: 30px; }

.nav-links a {
    font-family: var(--mono);
    font-size: 10.5px;
    letter-spacing: .24em;
    text-transform: uppercase;
    color: var(--grey);
    transition: color var(--hover);
}

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

@media (max-width: 900px) {
    .nav-links { gap: 20px; }
}

@media (max-width: 600px) {
    .nav { flex-direction: column; align-items: flex-start; gap: 10px; padding-top: 14px; padding-bottom: 14px; }
    /* The wordmark is the first thing in the hero anyway, so on a phone the nav
       copy of it only costs header height. Hiding it also drops the tap target
       that returned to #top. */
    .nav-mark { display: none; }
    .nav-links { gap: 0; justify-content: space-between; width: 100%; }
    /* Five links spread across a 320px viewport leave no gap at the full-size
       tracking, so the labels touch. */
    .nav-links a { font-size: 10px; letter-spacing: .14em; }
}

/* ── hero ── */
.hero {
    position: relative;
    min-height: calc(100vh - 40px);
    min-height: calc(100dvh - 40px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 80px var(--edge);
}

.hero::before {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: radial-gradient(44% 38% at 50% 50%, color-mix(in srgb, var(--violet) 10%, transparent), transparent 70%);
}

.hero-mark {
    position: relative;
    /* Safari, on Mac and iOS alike, applies the noise layers' masks to their
       line box but lets the filter paint across the glyphs' taller ink box, so
       a hard band of unmasked noise sat above and below the word. Chromium
       clips to the line box already, and the mask feathers fade to nothing at
       its edge, so this clip changes nothing there. */
    overflow: hidden;
    /* 13.5vw, not 16.5vw. The cap is a straight 1.5x (92px -> 138px), but the
       slope is not: "DIFFUSION" measures 6.083em wide at wght800/wdth110, so a
       1.5x slope overflows the viewport at 375px, 430px and 768px. 13.5vw is the
       widest slope that clears every width from 320px up with real margin. */
    font-size: clamp(2.5rem, 13.5vw, 8.625rem);
    line-height: .92;
    font-weight: 800;
    font-stretch: 110%;
    letter-spacing: -.025em;
    color: var(--bone);
}

/* Entrance, plays once on load: the wordmark resolves out of noise, left to
   right — the D clears first, the N last.

   Three stacked renderings of the word, each revealed through its own mask:
   full noise on top, half-resolved beneath it, clean text at the bottom. The
   masks are offset from one another, so what travels across the word is a band
   of partial resolution — clean behind it, half-formed inside it, pure noise
   ahead of it. A two-layer version could only crossfade between "noise" and
   "clean"; the middle layer is what gives genuinely intermediate states.

   Each mask is 300% wide, so its element sees one third at a time: at position
   100% the right third, at 0% the left third. Both filters are static, so they
   rasterize once — only mask-position animates.

   The clean layer's mask is on .mark-text and the middle layer's is on
   .mark-mid::before. .mark-mid itself carries no mask deliberately: a mask on it
   would also mask .mark-text inside it, and the layers need independent masks.

   The first 12% holds at pure noise before the front sets off, so it reads as
   starting from noise rather than already resolving.

   The mask gradients' colour is irrelevant (an alpha mask reads only opacity);
   they use var(--bone) rather than black to keep the stylesheet palette-pure. */
.hero-mark::before,
.mark-mid::before {
    content: 'DIFFUSION';
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    color: var(--bone);
    pointer-events: none;
    -webkit-mask-size: 300% 100%;
            mask-size: 300% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
}

/* top: a field of pure noise, cleared from the left.
   Three mask layers intersected: the travelling wipe, plus a vertical and a
   horizontal feather so the field has no hard edge anywhere. Without the
   feathers the noise is a plain rectangle, which is the box that kept showing
   up around the lettering. Only the first layer's position animates. */
.hero-mark::before {
    z-index: 3;
    filter: url(#mark-noise-full);
    -webkit-mask-image: linear-gradient(90deg, transparent 0%, transparent 52%, var(--bone) 66%, var(--bone) 100%),
                        linear-gradient(180deg, transparent 0%, var(--bone) 20%, var(--bone) 80%, transparent 100%),
                        linear-gradient(90deg, transparent 0%, var(--bone) 8%, var(--bone) 92%, transparent 100%);
            mask-image: linear-gradient(90deg, transparent 0%, transparent 52%, var(--bone) 66%, var(--bone) 100%),
                        linear-gradient(180deg, transparent 0%, var(--bone) 20%, var(--bone) 80%, transparent 100%),
                        linear-gradient(90deg, transparent 0%, var(--bone) 8%, var(--bone) 92%, transparent 100%);
    -webkit-mask-size: 300% 100%, 100% 100%, 100% 100%;
            mask-size: 300% 100%, 100% 100%, 100% 100%;
    -webkit-mask-composite: source-in;
            mask-composite: intersect;
    animation: mark-front-feathered 3.4s cubic-bezier(.35,0,.5,1) both,
               mark-retire 3.45s step-end both;
}

/* Only the travelling layer's position moves; the two feathers are pinned. A
   feathered layer must use this and not mark-front, whose single position would
   slide the feathers along with the band. */
@keyframes mark-front-feathered {
    0%, 12% { -webkit-mask-position: 100% 0, 0 0, 0 0; mask-position: 100% 0, 0 0, 0 0; }
    100%    { -webkit-mask-position: 0% 0, 0 0, 0 0;   mask-position: 0% 0, 0 0, 0 0; }
}

/* middle: half-resolved, visible only as a travelling band.
   Feathered like the noise layer above, and for the same reason: the blur and
   displacement throw ink well outside the element box, the box is exactly the
   width of the word because .hero is a centring flex column, and without the
   feathers that overspill is cut square — a grain-filled rectangle straddling
   the last N. */
.mark-mid::before {
    z-index: 2;
    filter: url(#mark-noise-mid);
    -webkit-mask-image: linear-gradient(90deg, transparent 0%, transparent 36%, var(--bone) 48%, var(--bone) 54%, transparent 66%, transparent 100%),
                        linear-gradient(180deg, transparent 0%, var(--bone) 20%, var(--bone) 80%, transparent 100%),
                        linear-gradient(90deg, transparent 0%, var(--bone) 8%, var(--bone) 92%, transparent 100%);
            mask-image: linear-gradient(90deg, transparent 0%, transparent 36%, var(--bone) 48%, var(--bone) 54%, transparent 66%, transparent 100%),
                        linear-gradient(180deg, transparent 0%, var(--bone) 20%, var(--bone) 80%, transparent 100%),
                        linear-gradient(90deg, transparent 0%, var(--bone) 8%, var(--bone) 92%, transparent 100%);
    -webkit-mask-size: 300% 100%, 100% 100%, 100% 100%;
            mask-size: 300% 100%, 100% 100%, 100% 100%;
    -webkit-mask-composite: source-in;
            mask-composite: intersect;
    animation: mark-front-feathered 3.4s cubic-bezier(.35,0,.5,1) both,
               mark-retire 3.45s step-end both;
}

@media (max-width: 600px) {
    /* The middle layer's displacement and blur are absolute lengths, so at phone
       glyph sizes the stock filter tears the letters apart. Swap in the half-scale
       twin; the top layer is pure turbulence and needs nothing. */
    .mark-mid::before { filter: url(#mark-noise-mid-sm); }
}

/* bottom: the real text, arriving from the left */
.mark-text {
    display: block;
    position: relative;
    z-index: 1;
    -webkit-mask-image: linear-gradient(90deg, var(--bone) 0%, var(--bone) 34%, transparent 50%, transparent 100%);
            mask-image: linear-gradient(90deg, var(--bone) 0%, var(--bone) 34%, transparent 50%, transparent 100%);
    -webkit-mask-size: 300% 100%;
            mask-size: 300% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    animation: mark-front 3.4s cubic-bezier(.35,0,.5,1) both;
}

@keyframes mark-front {
    0%, 12% { -webkit-mask-position: 100% 0; mask-position: 100% 0; }
    100%    { -webkit-mask-position: 0% 0;   mask-position: 0% 0; }
}

/* Retiring these layers needs opacity and display, NOT visibility. The noise
   filter is pure feTurbulence — it generates its output without reading
   SourceGraphic — so `visibility: hidden` empties the element's own paint while
   the filter keeps emitting noise, and in that state the mask stops suppressing
   it. The result was a hard-edged noise rectangle appearing after the entrance
   finished. opacity applies to the filtered result, so it hides it for certain;
   display:none additionally stops the filter rasterizing at all. */
@keyframes mark-retire {
    from { opacity: 1; display: block; }
    to   { opacity: 0; display: none; }
}

/* Edge sweep: a violet outline of the wordmark, revealed through a narrow
   travelling mask, so a lit hairline runs across the letterforms. Only the
   stroke is ever lit — the bone fill underneath never changes.
   The h1 carries aria-label="Diffusion", so the duplicated text in this
   pseudo-element is never announced twice.
   Delayed to start when the entrance ends, with fill-mode backwards so it
   holds its opening frame during the delay instead of rendering its base
   mask position. See the entrance block above. */
.hero-mark::after {
    content: 'DIFFUSION';
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    color: transparent;
    -webkit-text-stroke: 1.1px var(--violet);
    pointer-events: none;
    -webkit-mask-image: linear-gradient(100deg, transparent 38%, var(--bone) 50%, transparent 62%);
            mask-image: linear-gradient(100deg, transparent 38%, var(--bone) 50%, transparent 62%);
    -webkit-mask-size: 320% 100%;
            mask-size: 320% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    animation: edge-sweep 5.5s linear 3.2s infinite backwards;
}

@keyframes edge-sweep {
    from { -webkit-mask-position: 170% 0;  mask-position: 170% 0; }
    to   { -webkit-mask-position: -70% 0;  mask-position: -70% 0; }
}

.hero-tag {
    position: relative;
    margin-top: 36px;
    font-family: var(--mono);
    font-size: 16.5px;
    letter-spacing: .34em;
    text-transform: uppercase;
    color: var(--grey);
}

@media (max-width: 600px) {
    /* Three places instead of two overruns the measure at the full size and
       wraps. Shrunk to hold one line at 320 too, and to stay narrower than the
       wordmark above it, which is 263px of ink there. */
    .hero-tag { font-size: 13.5px; letter-spacing: .26em; }
}

/* Decorative separator: aria-hidden in the markup, so it is pure decoration
   and exempt from WCAG 1.4.3. Do not "fix" this to a brighter value. */
.hero-tag span { color: color-mix(in srgb, var(--bone) 34%, transparent); margin: 0 .8em; }

.hero-soc {
    position: relative;
    margin-top: 42px;
    display: flex;
    gap: 33px;
}

.hero-soc a { color: var(--bone); opacity: .82; transition: color var(--hover), opacity var(--hover); }
.hero-soc a:hover { color: var(--violet); opacity: 1; }
.hero-soc svg { display: block; }

/* ── section shell ── */
/* scroll-margin-top ≈ the sticky nav's rendered height (20+20 padding + ~22.5px
   line box + 1px border ≈ 63.5px). .sec's padding-top absorbs the rounding, so
   anchored headings clear the nav. At 600px the nav drops the wordmark and
   measures 44px, so the margin comes down to match.
   The top padding is shorter than the bottom on purpose: it ties a header to the
   rule above it rather than leaving it adrift between two sections. */
.sec {
    padding: 52px var(--edge) 74px;
    border-top: 1px solid var(--line);
    scroll-margin-top: 62px;
}

.sechead {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 30px;
}

.sechead h2 {
    font-family: var(--mono);
    font-size: 13px;
    font-weight: 500;
    letter-spacing: .34em;
    text-transform: uppercase;
    color: var(--bone);
}

.sechead .count {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: .2em;
    text-transform: uppercase;
    color: var(--grey);
}

@media (max-width: 600px) {
    .sec { scroll-margin-top: 42px; padding-top: 38px; padding-bottom: 56px; }
    .sechead { margin-bottom: 24px; }
}

/* ── sets ── */
.set-grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 28px 24px;
}

/* Column ladder: 4 -> 3 -> 2 -> 2. These blocks must stay in descending
   max-width order; they are equal specificity, so a later block wins and
   reordering would strand the grid at the wrong count. */
@media (max-width: 1200px) {
    .set-grid { grid-template-columns: repeat(3, 1fr); }
}

@media (max-width: 900px) {
    .set-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 600px) {
    /* Two columns, not one: six full-width squares stack to more than 2000px
       and lose the grid the desktop page is built around. */
    .set-grid { grid-template-columns: repeat(2, 1fr); gap: 22px 14px; }
}

.tile { display: block; }

.tile-art {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 1 / 1;
    object-fit: cover;
    border-radius: 2px;
    background: var(--panel);
    transition: transform .25s cubic-bezier(.4, 0, .2, 1), box-shadow .25s;
}

.tile:hover .tile-art,
.tile:focus-visible .tile-art {
    transform: translateY(-3px);
    box-shadow: 0 14px 44px color-mix(in srgb, var(--void) 55%, transparent);
}

.tile-cap {
    display: block;
    margin-top: 13px;
    font-size: 14px;
    font-weight: 500;
    line-height: 1.35;
    color: var(--bone);
}

@media (max-width: 600px) {
    .tile-cap { font-size: 13px; margin-top: 10px; }
}

.tile-meta {
    display: block;
    margin-top: 5px;
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--grey);
    transition: color var(--hover);
}

.tile:hover .tile-meta,
.tile:focus-visible .tile-meta { color: var(--violet); }

/* ── photos ── */
/* js/photos.js sizes and places every shot from its image's own aspect
   ratio, aiming each row at --photo-row, then adds .is-laid. Until it runs,
   or without it, the photos wrap as a strip at that height, uncropped. */
.photo-grid {
    --photo-row: 480px;
    display: flex;
    flex-wrap: wrap;
    gap: 14px;
}

@media (max-width: 900px) {
    .photo-grid { --photo-row: 340px; }
}

@media (max-width: 600px) {
    .photo-grid { --photo-row: 240px; gap: 10px; }
}

.shot-stack { display: contents; }

.shot {
    position: relative;
    height: var(--photo-row);
    border-radius: 2px;
    overflow: hidden;
    background: var(--panel);
}

.shot img {
    display: block;
    height: 100%;
    width: auto;
}

.photo-grid.is-laid { display: block; position: relative; }
.is-laid .shot { position: absolute; }
.is-laid .shot img { width: 100%; }

.shot-btn {
    appearance: none;
    background: none;
    border: 0;
    padding: 0;
    display: block;
    width: 100%;
    height: 100%;
    cursor: pointer;
}

.shot-btn img { transition: filter var(--hover); }

.shot-btn:hover img,
.shot-btn:focus-visible img { filter: brightness(1.12); }

/* Inset, because .shot clips anything drawn outside it. */
.shot-btn:focus-visible { outline-offset: -2px; }

/* ── dialogs ──
   The lightbox is a native <dialog> opened with showModal(), which
   gives the focus trap, Escape, the inert page behind and focus restore. The
   dialog element itself is the full-viewport dimmed, blurred layer, so a click
   on it outside the content is a click on the backdrop; the browser's own
   ::backdrop is switched off. The user agent styles a dialog as a small white
   box with black text, so every one of those defaults is reset here. */
dialog {
    position: fixed;
    inset: 0;
    width: auto;
    height: auto;
    max-width: none;
    max-height: none;
    margin: 0;
    padding: var(--gut);
    border: 0;
    color: var(--bone);
    background: color-mix(in srgb, var(--void) 72%, transparent);
    overflow-y: auto;
    overscroll-behavior: contain;
    backdrop-filter: blur(14px);
    opacity: 0;
    transition: opacity var(--fade);
}

/* [open], not bare `dialog`: a display on the element itself would beat the
   user agent's `dialog:not([open]) { display: none }`. */
dialog[open] { display: flex; }

dialog::backdrop { background: none; }

dialog.is-open { opacity: 1; }

body:has(dialog[open]) { overflow: hidden; }

/* Absolute, not fixed: the dialog is the containing block, and it covers
   the viewport, so this is the viewport corner. */
.dialog-close {
    position: absolute;
    top: 16px;
    right: 18px;
    z-index: 1;
    appearance: none;
    background: none;
    border: 0;
    padding: 6px;
    cursor: pointer;
    font-family: var(--mono);
    font-size: 18px;
    line-height: 1;
    color: var(--grey);
    transition: color var(--hover);
}

.dialog-close:hover,
.dialog-close:focus-visible { color: var(--bone); }

/* ── lightbox ── */
.lightbox { background: color-mix(in srgb, var(--void) 82%, transparent); }

.lightbox-img {
    margin: auto;
    display: block;
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: 2px;
    transform: translateY(12px);
    transition: transform var(--fade) cubic-bezier(.4, 0, .2, 1);
}

.lightbox.is-open .lightbox-img { transform: none; }

/* ── gigs ── */
/* One grid serves Upcoming and Played, so a show moves between the two
   sections by editing its date and dropping .gig--next.
   The header row shares the gig grid, so PLAYED sits over the date column and
   LOCATION exactly over the city column. It was previously a flex row with
   space-between while the rows below were a 3-column grid, so the right-hand
   label missed the city column by the width of .gig's horizontal padding. */
.sechead--cols {
    display: grid;
    grid-template-columns: 104px minmax(0, 1fr) 140px;
    gap: 18px;
    /* Overrides .sechead's baseline alignment: PLAYED is 13px and LOCATION is
       10px, so a shared baseline left LOCATION's cap band sitting low. */
    align-items: center;
}

.sechead--cols .count {
    grid-column: 3;
    text-align: right;
}

.gig {
    display: grid;
    grid-template-columns: 104px minmax(0, 1fr) 140px;
    gap: 18px;
    /* Centred, not baseline: the mono labels are 10px caps against a 15px
       mixed-case title, so a shared baseline leaves their cap band sitting a
       pixel and a half low. Centring closes that to a quarter pixel. */
    align-items: center;
    /* No horizontal padding — it would offset every cell from the header above. */
    padding: 20px 0;
}

@media (max-width: 600px) {
    /* Keep the desktop's three columns so LOCATION still sits over the cities.
       The venue column is the one that gives; a long name wraps rather than
       pushing the city onto its own line. */
    .sechead--cols,
    .gig { grid-template-columns: 68px minmax(0, 1fr) auto; gap: 14px; }
}

.gig-date,
.gig-city {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: .15em;
    text-transform: uppercase;
    color: var(--grey);
}

.gig-city { text-align: right; }

.gig-venue { font-size: 15px; font-weight: 500; color: var(--bone); }

/* Upcoming rows only. .gig-note is its own cell rather than a child of
   .gig-venue so the mobile stack keeps the played order: venue, city, then
   the ticket line. */
.gig--next { row-gap: 5px; }

.gig-note {
    grid-column: 2;
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--grey);
    transition: color var(--hover);
}

.gig--next:hover .gig-note,
.gig--next:focus-visible .gig-note { color: var(--violet); }

/* Played rows link the venue name to the event's listing. The faint
   underline is the only mark: the link is proof the gig happened, not a
   ticket sale for a passed date. */
a.gig-venue {
    justify-self: start;
    text-decoration: underline;
    text-decoration-color: color-mix(in srgb, var(--bone) 28%, transparent);
    text-decoration-thickness: 1px;
    text-underline-offset: 5px;
    transition: text-decoration-color var(--hover);
}

a.gig-venue:hover,
a.gig-venue:focus-visible { text-decoration-color: var(--violet); }

/* ── booking ── */
.book { text-align: center; padding-top: 72px; padding-bottom: 96px; }

.book-label {
    font-family: var(--mono);
    font-size: 13px;
    font-weight: 500;
    letter-spacing: .34em;
    text-transform: uppercase;
    color: var(--grey);
}

.book-mail {
    display: inline-block;
    margin-top: 22px;
    font-size: clamp(1.15rem, 4.4vw, 1.875rem);
    font-weight: 700;
    font-stretch: 108%;
    letter-spacing: -.015em;
    color: var(--bone);
    word-break: break-word;
    transition: color var(--hover);
}

.book-mail:hover { color: var(--violet); }

/* ── footer ── */
.foot {
    display: flex;
    justify-content: space-between;
    gap: 16px;
    padding: 24px var(--edge) 36px;
    border-top: 1px solid var(--line);
    font-family: var(--mono);
    font-size: 9.5px;
    letter-spacing: .2em;
    text-transform: uppercase;
    color: var(--grey);
}

@media (max-width: 600px) {
    .foot { flex-direction: column; gap: 8px; }
}

/* ── reduced motion ── */
/* This OS setting has twice been mistaken for the wordmark animation being
   broken. Check it before debugging the entrance. */
@media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }

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

    .tile:hover .tile-art,
    .tile:focus-visible .tile-art {
        transform: none;
    }

    /* `animation: none` alone would freeze the sweep mid-travel, leaving a violet
       outline stuck across part of the wordmark, and would strand the entrance
       filter at its opening frame — a permanent field of noise where the wordmark
       should be. Remove the sweep and the filter outright; the h1 then simply
       renders as plain text, immediately. */
    .hero-mark::after,
    .hero-mark::before, .mark-mid::before { display: none; }
    .mark-text { mask-image: none; -webkit-mask-image: none; animation: none; }
}
