/* ============================================================
   engine.css - the style of everything the engine draws or drives:
   the base its parts stand on, the carousel, the gallery grid, the
   lightbox and the drawer, the Latest block, the reveal, the blog
   stream and its month pages, and the players.

   Every page links this file first and the host's site.css second,
   so a host rule wins a tie. The editor injects its own rules into
   <head> at runtime, after both files, so the editor wins a tie with
   either.

   THE THEME. Every color, font, space, shape and motion value here is
   a token on :root. Section 1 gives each token a default, so a host
   that sets none still gets a readable engine. A host sets a token in
   its own :root to change it. The tokens are the contract. A custom
   property that a part sets on its own element is internal.

   Rule: do not reorder a block to tidy the file. CSS resolves ties
   by source order, so a moved block can change the page without
   changing a single declaration.

   Sections:
     1. FOUNDATION                  4. OVERLAYS
     2. CAROUSEL                    5. LATEST
     3. GALLERY                     6. REVEAL AND BLOG
   ============================================================ */

/* ============================================================
   1. FOUNDATION
   ------------------------------------------------------------
   The theme tokens and their defaults, the reset and the base, the
   shared type, and the edit launcher.
   ============================================================ */

/* ------------------------------------------------------------
   THE THEME TOKENS
   ------------------------------------------------------------
   The defaults. A host sets any of them in its own :root, which
   loads after this file. */
:root {
  /* surfaces */
  --bg:        #16181d;
  --bg-deep:   #101217;
  --panel:     #1e2128;
  --panel-2:   #23262f;
  --line:      rgba(255,255,255,.08);
  --line-soft: rgba(255,255,255,.05);

  /* text */
  --text:      #ebeef4;
  --text-soft: #c3c9d4;
  --muted:     #969eaa;
  --dim:       #6b7280;

  /* accent: the one color that marks what acts */
  --accent:        #4aa5e8;
  --accent-bright: #6fbcf2;
  --accent-deep:   #2e87cf;
  --accent-glow:   rgba(74,165,232,.35);
  --accent-faint:  rgba(74,165,232,.10);

  /* staged accents; the editor paints with the yellow and the orange */
  --c-orange: #f0883e;
  --c-yellow: #f2c14e;
  --c-lime:   #b5e853;

  /* type */
  --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          Helvetica, Arial, sans-serif;

  /* space */
  --section-pad: clamp(4rem, 9.5vw, 8rem);
  --gutter:      clamp(1.25rem, 5vw, 3rem);
  --maxw:        1180px;

  /* shape and motion */
  --radius:    16px;
  --radius-sm: 10px;
  --ease:      cubic-bezier(.22, 1, .36, 1);
  --ease-soft: cubic-bezier(.4, 0, .2, 1);
}

/* ------------------------------------------------------------
   RESET + BASE
   ------------------------------------------------------------ */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }

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

body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--text);
  line-height: 1.6;
  font-size: clamp(1rem, .55vw + .9rem, 1.125rem);
  font-feature-settings: "cv05" 1, "ss01" 1, "cv02" 1;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

img { max-width: 100%; height: auto; display: block; }
a { color: inherit; text-decoration: none; }

::selection { background: var(--accent); color: #06121d; }

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

/* ------------------------------------------------------------
   SHARED TYPE
   ------------------------------------------------------------
   The eyebrow, the text link and the text sizes. Pages carry them,
   and the generator and the editor write them too. */
.eyebrow {
  font-size: .8rem;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--accent);
  display: inline-flex;
  align-items: center;
  gap: .65rem;
}
.eyebrow::before {
  content: "";
  width: 30px; height: 2px;
  background: var(--accent);
  display: inline-block;
}
/* centered variant — used by the Contact closing CTA */
.eyebrow--center { justify-content: center; }

.textlink {
  display: inline-flex; align-items: center; gap: .45rem;
  color: var(--accent); font-weight: 600;
  border-bottom: 1px solid transparent;
  transition: border-color .3s, gap .3s var(--ease), color .3s;
  width: fit-content;
}
.textlink:hover { border-bottom-color: var(--accent); gap: .75rem; color: var(--accent-bright); }
.textlink svg { width: 1em; height: 1em; }

/* text-size utilities — the copy sizes this page already uses, exposed as
   named classes for inline <span> wraps (the built-in site editor's toolbar
   inserts these; safe to hand-author anywhere). em-based, so each step is
   relative to the text it sits inside:
     xs ≈ chip size (.76rem on body copy) · sm ≈ meta/eyebrow (.85rem)
     lg ≈ lede step-up (1.15×)            · xl ≈ full lede (1.4×) */
.text-xs { font-size: .76em; }
.text-sm { font-size: .85em; }
.text-lg { font-size: 1.15em; }
.text-xl { font-size: 1.4em; }


/* ------------------------------------------------------------
   THE EDIT LAUNCHER
   ------------------------------------------------------------
   The page corner peels back to show the word underneath. tool.js
   builds it at load, on every page, so nothing here is authored into
   a file and nothing here can reach an export.

   The whole thing is one SVG, and its paint order does the layering:
   the page under the corner, then the word, then the flap. SVG draws
   in document order, so the flap covers the word and the curl
   uncovers it. There is no z-index inside the button.

   The free edge of the flap is a curve rather than a crease, which is
   what makes it read as paper that rolled rather than folded.

   Above everything: the editor's panel is 3100, its scrim 3200, its
   modal 3300, the composer 3300. The way out of the editor must never
   be behind the editor.

   Which corner comes from data-corner, which tool.js sets. The art is
   drawn for the bottom left and the other three mirror it, so moving
   it is one word in tool.js and no new path data. */
.amh-edit {
  position: fixed;
  z-index: 4000;
  width: 132px;
  height: 132px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  opacity: .5;
  transition: opacity .34s var(--ease-soft);
}
.amh-edit[data-corner="bottom-left"]  { left: 0;  bottom: 0; }
.amh-edit[data-corner="bottom-right"] { right: 0; bottom: 0; transform: scaleX(-1); }
.amh-edit[data-corner="top-left"]     { left: 0;  top: 0;    transform: scaleY(-1); }
.amh-edit[data-corner="top-right"]    { right: 0; top: 0;    transform: scale(-1); }

.amh-edit__art { display: block; width: 100%; height: 100%; overflow: visible; }

/* The paper. Both paths scale from the corner itself, so the peel grows out
   of it rather than sliding in from somewhere else. */
.amh-edit__paper {
  transform-box: view-box;
  transform-origin: 0 150px;
  transform: scale(.30);
  transition: transform .55s var(--ease);
}
.amh-edit__hole { fill: url(#amhHole); }
.amh-edit__flap {
  fill: url(#amhFlap);
  filter: drop-shadow(-4px 4px 9px rgba(0, 0, 0, .72));
}

/* The word sits between the two, so it needs no stacking rule of its own,
   and it is clipped to the hole, so it is drawn only where the page has
   peeled away. There is no opacity transition: a second animation on a
   timer could show the word over paper that had not lifted yet, and would
   reverse at a different rate when the pointer left. One motion drives the
   paper, the clip and the reveal together. */
.amh-edit__word {
  fill: var(--accent-bright);
  font: 700 13px/1 Consolas, "Courier New", monospace;
  letter-spacing: .24em;
}
.amh-edit__word--exit { display: none; }
.amh-edit.is-on .amh-edit__word--edit { display: none; }
.amh-edit.is-on .amh-edit__word--exit { display: block; }

.amh-edit:hover,
.amh-edit:focus-visible,
.amh-edit.is-on { opacity: 1; }

.amh-edit:hover .amh-edit__paper,
.amh-edit:focus-visible .amh-edit__paper,
.amh-edit.is-on .amh-edit__paper { transform: scale(1); }

.amh-edit.is-on .amh-edit__word { fill: var(--accent); }

/* The button has no box of its own, so the focus ring goes on the art. */
.amh-edit:focus-visible { outline: none; }
.amh-edit:focus-visible .amh-edit__flap {
  filter: drop-shadow(-4px 4px 9px rgba(0, 0, 0, .72))
          drop-shadow(0 0 0 2px var(--accent));
}

@media (prefers-reduced-motion: reduce) {
  .amh-edit, .amh-edit__paper { transition: none; }
}

/* ============================================================
   2. CAROUSEL
   ------------------------------------------------------------
   The image carousel that work.js builds from a plain list of
   <img>: on a project card, in a deep dive and in a blog post.
   ============================================================ */

/* ------------------------------------------------------------
   PROJECT IMAGE GALLERY
   Authoring stays simple: each project owns a plain `.gallery`
   list of <img> tags. JS turns that into a restrained, cinematic
   image stage with an in-frame HUD, progress timeline, swipe,
   and lightbox support.
   ------------------------------------------------------------ */

/* Fallback for a gallery work.js has not built yet, or never will.
   Show the first image only. All of them would stack down the page. */
.gallery:not(.is-ready) > img { display: none; width: 100%; border-radius: var(--radius); }
.gallery:not(.is-ready) > img:first-of-type { display: block; }

.gallery.is-ready { display: block; width: 100%; min-width: 0; }

/* The holder stacks the view and, when a photo has a caption, the caption
   strip under it. The frame layer fills the holder behind both: the ambient
   backdrop, the progress timeline, and the optional preview chip.

   Its width keeps the view no taller than --gallery-screen of the screen's
   height, so a tall frame gets narrower and stays centered. work.js writes
   both numbers: the ratio on the holder, the share of the screen on the
   body. The vh line stands in for a browser that has no svh. */
.gallery__holder {
  position: relative;
  width: 100%;
  width: min(100%, calc(var(--gallery-screen, .85) * 100vh * var(--gallery-ratio, 1.7778)));
  width: min(100%, calc(var(--gallery-screen, .85) * 100svh * var(--gallery-ratio, 1.7778)));
  max-width: 100%;
  margin-inline: auto;
  isolation: isolate;
  cursor: zoom-in;          /* the image area opens the lightbox */
  touch-action: pan-y;      /* vertical scroll stays native; horizontal = swipe */
}

/* The view: the photo area, in the frame's shape. work.js works the shape
   out from the photos or takes the wrapper's data-shape; see its section 5. */
.gallery__view {
  position: relative;
  aspect-ratio: var(--gallery-ratio, 16 / 9);
}

/* Glassy media surface. The active image is also mirrored into a blurred
   ambience layer, so screenshots/photos color the frame without extra assets. */
.gallery__frame {
  position: absolute; inset: 0;
  overflow: hidden;
  border-radius: 10px;
  border: 1px solid rgba(255,255,255,.11);
  background:
    radial-gradient(140% 120% at 50% 0%, rgba(255,255,255,.06), rgba(255,255,255,0) 56%),
    #090c12;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.10),
    inset 0 -1px 0 rgba(0,0,0,.52),
    0 28px 80px -44px rgba(0,0,0,1);
}
.gallery__frame::before,
.gallery__frame::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.gallery__frame::before {
  z-index: 0;
  background:
    radial-gradient(80% 80% at 18% 12%, rgba(74,165,232,.16), rgba(74,165,232,0) 58%),
    radial-gradient(64% 72% at 88% 88%, rgba(255,255,255,.07), rgba(255,255,255,0) 62%),
    linear-gradient(120deg, rgba(255,255,255,.055), transparent 28%, transparent 72%, rgba(255,255,255,.035));
}
.gallery__frame::after {
  z-index: 4;
  background:
    linear-gradient(180deg, rgba(5,8,12,.16), transparent 28%, transparent 54%, rgba(4,7,12,.66)),
    linear-gradient(90deg, rgba(0,0,0,.20), transparent 22%, transparent 78%, rgba(0,0,0,.18));
}
.gallery__ambient {
  position: absolute;
  inset: -16%;
  width: 132%;
  height: 132%;
  object-fit: cover;
  z-index: 0;
  opacity: 0;
  pointer-events: none;
  filter: blur(34px) saturate(1.16) brightness(.72);
  transform: scale(1.05);
  transition: opacity .52s var(--ease), transform .75s var(--ease);
}
.gallery__ambient.is-active {
  opacity: .46;
  transform: scale(1.02);
}

/* The active work image stays contained and inspectable. */
.gallery__stage {
  position: absolute;
  inset: clamp(14px, 2.45%, 26px);
  z-index: 2;
  border-radius: 7px;
}

/* each photo: contained (any ratio fits, never stretched), centered,
   floating on the holder with a faint drop shadow, cross-faded. */
.gallery__img {
  position: absolute; inset: 0; margin: auto;       /* centered in the stage */
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;        /* shrink large photos to fit; never upscale
                                       a small one to mush (it stays crisp + centered) */
  opacity: 0;
  pointer-events: none;
  border-radius: 6px;
  transform: translateZ(0) scale(.985);
  transition: opacity .46s var(--ease), transform .5s var(--ease), filter .5s var(--ease);
  filter: drop-shadow(0 20px 34px rgba(0,0,0,.46));
}
.gallery__img.is-active { opacity: 1; transform: translateZ(0) scale(1); }
/* Display True Pixel Size. The frame already draws a photo at its own size.
   Its corners are not rounded, because a rounded corner clips the pixels
   of a small image. A small photo keeps hard pixel edges where a screen
   draws it larger than its file; the engine writes data-crisp only on a
   photo that small. */
.gallery__img[data-truesize] { border-radius: 0; }
.gallery__img[data-crisp] { image-rendering: pixelated; }

.gallery__hud {
  position: absolute;
  inset: clamp(10px, 2.2%, 18px);
  z-index: 7;
  pointer-events: none;
}

/* THE CAPTION STRIP. It sits under the view, on the frame's backdrop, so a
   caption never covers a photo and never waits for a hover. It holds the
   label at the left and the arrow rocker at the right. A carousel whose
   photos have no caption has no strip. The strip takes no pointer: a tap on
   it falls through to the frame and opens the viewer, as a tap on the photo
   does, and the rocker asks for the pointer back. */
.gallery__strip {
  position: relative;
  z-index: 7;
  display: flex;
  align-items: center;
  gap: .6rem;
  padding: 0 clamp(10px, 2.2%, 18px) clamp(12px, 2vw, 18px);
  pointer-events: none;
}

/* The editorial label: a left rule, a dot, and the caption, always shown. */
.gallery__caption {
  flex: 0 1 auto;
  min-width: 0;
  max-width: min(100%, 36rem);
  pointer-events: none;     /* a quiet label; taps fall through to swipe / lightbox */
  display: flex;
  align-items: center;
  min-height: 2.18rem;
  padding: .48rem .78rem;
  border-left: 2px solid rgba(111,188,242,.76);
  border-radius: 0 8px 8px 0;
  background:
    linear-gradient(90deg, rgba(6,9,14,.84), rgba(6,9,14,.54) 74%, rgba(6,9,14,0));
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.07),
    0 18px 44px -30px rgba(0,0,0,.95);
  -webkit-backdrop-filter: blur(14px) saturate(1.14);
          backdrop-filter: blur(14px) saturate(1.14);
}
/* A photo with no caption keeps the label's place, so the strip keeps one
   height from photo to photo. */
.gallery__caption.is-empty { visibility: hidden; }
.gallery__caption::before {
  content: "";
  flex: 0 0 auto;
  width: .42rem;
  height: .42rem;
  margin-right: .52rem;
  border-radius: 50%;
  background: var(--accent-bright);
  box-shadow: 0 0 18px rgba(74,165,232,.35);
}
/* One grid cell holds the caption on screen and a hidden copy of every
   caption in the carousel, so the cell is as tall as the longest caption at
   its width. A caption takes two lines at most. */
.gallery__caption-cell {
  display: grid;
  flex: 1 1 auto;
  min-width: 0;
}
.gallery__caption-text,
.gallery__caption-sizer {
  grid-area: 1 / 1;
  align-self: center;       /* a short caption sits in the middle of a tall cell, not at its top */
  min-width: 0;
  text-align: left;
  color: rgba(235,238,244,.94);
  font-size: .75rem;
  font-weight: 780;
  letter-spacing: 0;
  line-height: 1.4;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  text-shadow: 0 1px 12px rgba(0,0,0,.5);
}
.gallery__caption-sizer { visibility: hidden; }
/* the display rule above would otherwise keep a hidden caption on screen */
.gallery__caption-text[hidden] { display: none; }
.gallery__counter {
  position: absolute;
  top: 0;
  right: 0;
  min-width: 3.7rem;
  padding: .46rem .58rem;
  text-align: center;
  color: rgba(235,238,244,.92);
  font-size: .66rem;
  font-weight: 900;
  line-height: 1;
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  border: 1px solid rgba(255,255,255,.105);
  border-radius: 999px;
  background: rgba(8,12,18,.52);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 14px 36px -28px rgba(0,0,0,.9);
  -webkit-backdrop-filter: blur(14px) saturate(1.14);
          backdrop-filter: blur(14px) saturate(1.14);
  text-shadow: 0 0 16px rgba(74,165,232,.18);
  pointer-events: none;
  opacity: 0;
  transform: translateY(-6px);
  /* Slow, soft fade so the count glides in on a change and dissolves back out
     (the JS reveal timer holds it visible in between). */
  transition: opacity .62s var(--ease-soft), transform .62s var(--ease-soft);
}
.gallery__counter.is-visible,
.gallery__holder:focus-within .gallery__counter {
  opacity: 1;
  transform: translateY(0);
}
@media (hover: hover) and (pointer: fine) {
  .gallery__holder:hover .gallery__counter {
    opacity: 1;
    transform: translateY(0);
  }
}
.gallery__nav-cluster {
  position: absolute;
  right: 0;
  bottom: clamp(12px, 2vw, 18px);
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: .18rem;
  padding: .24rem;
  border: 1px solid rgba(255,255,255,.11);
  border-radius: 999px;
  background: rgba(8,12,18,.58);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.09),
    0 18px 42px -28px rgba(0,0,0,.95);
  -webkit-backdrop-filter: blur(16px) saturate(1.18);
          backdrop-filter: blur(16px) saturate(1.18);
}
.gallery__nav-cluster::before {
  content: "";
  position: absolute;
  left: 50%;
  top: .58rem;
  bottom: .58rem;
  width: 1px;
  background: rgba(255,255,255,.09);
  transform: translateX(-50%);
}
/* In the strip the rocker is part of the row, at its right end. */
.gallery__strip .gallery__nav-cluster {
  position: relative;
  right: auto;
  bottom: auto;
  flex: none;
  margin-left: auto;
}

/* PREV / NEXT ARROWS — built once per used location (image / bar / dots) and
   placed there in the DOM; hidden by default and shown for the chosen
   placement per breakpoint via the body.ga-d-<mode> / .ga-m-<mode> classes.
   Base look is a minimal inline-SVG chevron; image mode overrides it below. */
.gallery__nav {
  display: none; place-items: center;   /* becomes grid when shown */
  border: 1px solid transparent;
  background: none;
  padding: 0;
  color: var(--muted); cursor: pointer;
  transition: color .2s, background .25s, border-color .25s, opacity .25s, transform .25s;
}
.gallery__nav svg { width: 15px; height: 15px; display: block; }
.gallery__nav:hover { color: var(--text); }

/* show the chosen placement for each breakpoint */
@media (min-width: 881px) {
  body.ga-d-image .gallery__nav--image { display: grid; }
  body.ga-d-bar   .gallery__nav--bar   { display: grid; }
  body.ga-d-dots  .gallery__nav--dots  { display: grid; }
}
@media (max-width: 880px) {
  body.ga-m-image .gallery__nav--image { display: grid; }
  body.ga-m-bar   .gallery__nav--bar   { display: grid; }
  body.ga-m-dots  .gallery__nav--dots  { display: grid; }
}

/* BAR placement: a floating two-button nav rocker. */
.gallery__nav--bar {
  flex: 0 0 auto;
  width: 2.18rem;
  height: 2.18rem;
  border-radius: 999px;
  color: rgba(235,238,244,.78);
  border-color: transparent;
  background: transparent;
}
.gallery__nav--bar:hover {
  color: #fff;
  background: rgba(74,165,232,.18);
  border-color: rgba(74,165,232,.20);
  transform: scale(1.04);
}

/* DOTS placement: chevrons flanking the centered progress rail if selected. */
.gallery__nav--dots { flex: 0 0 auto; width: 1.9rem; height: 1.9rem; border-radius: 999px; }
.gallery__nav--dots.gallery__nav--prev { margin-right: .15rem; }
.gallery__nav--dots.gallery__nav--next { margin-left: .15rem; }

/* IMAGE placement: circular overlay button on the photo's edges, revealed on
   hover / focus (and always shown on touch), like the original style. */
.gallery__nav--image {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 42px; height: 42px; z-index: 3; opacity: 0;
  border: 1px solid var(--line); border-radius: 50%;
  background: rgba(8,12,20,.55); color: var(--text-soft);
}
.gallery__nav--image.gallery__nav--prev { left: 12px; }
.gallery__nav--image.gallery__nav--next { right: 12px; }
.gallery__holder:hover .gallery__nav--image,
.gallery__nav--image:focus-visible { opacity: 1; }
.gallery__nav--image:hover { background: rgba(8,12,20,.85); border-color: var(--accent); color: #fff; }
@media (hover: none) { .gallery__nav--image { opacity: 1; } }

/* Full-width timeline at the bottom of the frame. Each segment remains a real
   button, but the visual reads like an integrated media scrub line. */
.gallery__dots {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 8;
  display: flex;
  flex-wrap: nowrap;
  justify-content: stretch;
  align-items: stretch;
  gap: 2px;
  margin: 0;
  height: 3px;
  pointer-events: auto;
}
.gallery__dot {
  position: relative;
  flex: 1 1 0;
  width: auto;
  height: 3px;
  padding: 0;
  overflow: hidden;
  border: 0;
  border-radius: 0;
  background: rgba(255,255,255,.16);
  cursor: pointer;
  opacity: 1;
  transition: background .25s, box-shadow .25s;
}
.gallery__dot img {
  display: none;
}
.gallery__dot:hover { background: rgba(255,255,255,.34); }
.gallery__dot.is-active {
  background: linear-gradient(90deg, var(--accent-bright), var(--accent-deep));
  box-shadow: 0 0 20px rgba(74,165,232,.34);
}

/* Optional next-image teaser, now a restrained top-right preview chip. */
.gallery__preview {
  position: absolute;
  left: clamp(12px, 2.35%, 20px);
  top: clamp(12px, 2.35%, 20px);
  width: 19%;
  max-width: 118px;
  aspect-ratio: 4 / 3;
  padding: 0;
  overflow: hidden;
  cursor: pointer;
  z-index: 7;
  opacity: .62;
  border: 1px solid rgba(255,255,255,.18);
  background: rgba(10,13,18,.62);
  box-shadow: 0 18px 42px -24px rgba(0,0,0,.95);
  border-radius: 8px;
  transform: translateY(-3px) scale(.98);
  transition: transform .28s var(--ease), border-color .28s, opacity .28s;
  -webkit-backdrop-filter: blur(12px);
          backdrop-filter: blur(12px);
}
.gallery__holder:hover .gallery__preview,
.gallery__preview:focus-visible {
  opacity: 1;
  transform: translateY(0) scale(1);
}
.gallery__preview:hover { border-color: rgba(74,165,232,.55); }
.gallery__preview img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* keyboard focus rings on the gallery controls (the arrow also reveals
   itself on focus via the rule above). */
.gallery__nav:focus-visible,
.gallery__dot:focus-visible,
.gallery__preview:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

/* single-image galleries: no arrows, no dots, no preview. */
.gallery--single .gallery__nav,
.gallery--single .gallery__dots,
.gallery--single .gallery__preview { display: none; }

@media (hover: none) {
  .gallery__preview { opacity: .86; transform: none; }
}

@media (max-width: 880px) {
  .gallery__stage { inset: clamp(10px, 2.8%, 18px); }
  .gallery__hud { inset: 9px; }
  .gallery__strip { padding: 0 9px 10px; }
  .gallery__caption {
    min-height: 2.08rem;
    padding: .4rem .62rem;
  }
  .gallery__caption-text,
  .gallery__caption-sizer { font-size: .72rem; }
  .gallery__counter { min-width: 3rem; font-size: .62rem; }
  .gallery__nav-cluster { padding: .2rem; }
  .gallery__nav--bar { width: 1.82rem; height: 1.82rem; }
  .gallery__preview { width: 22%; max-width: 96px; }
}

@media (max-width: 440px) {
  .gallery__caption { padding-inline: .54rem; }
  .gallery__caption::before { width: .34rem; height: .34rem; margin-right: .42rem; }
  .gallery__caption-text,
  .gallery__caption-sizer { font-size: .68rem; }
  .gallery__counter { min-width: 2.72rem; font-size: .58rem; }
  .gallery__nav--bar { width: 1.66rem; height: 1.66rem; }
  .gallery__nav--bar svg { width: 13px; height: 13px; }
  .gallery__preview { width: 25%; }
}

@media (prefers-reduced-motion: reduce) {
  .gallery__ambient, .gallery__img, .gallery__media, .gallery__nav, .gallery__preview, .gallery__counter { transition: none; }
}

/* ============================================================
   3. GALLERY
   ------------------------------------------------------------
   The gallery page's six-column train grid, which gallery.js
   packs, and its collapse on a narrow screen.
   ============================================================ */

/* ------------------------------------------------------------
   THE SIX-COLUMN TRAIN GRID
   ------------------------------------------------------------
   One project is one train. A train opens with its title band,
   spanning all six columns and exactly one row tall, and then its
   tiles.

   The invariant: every tile is exactly one row tall, and the widths
   in a row add to exactly six. An interior hole is therefore
   impossible rather than avoided, which is why the packer in Phase 4
   Part 2 is a small problem instead of a large one. Only the last row
   of a train may be short.

   The row height is one value for the whole grid. A tile that is
   wider is more panoramic, not taller, and that is the look.

   data-span is the width a tile is DRAWN at. data-w, on the same
   element, is the width the image PREFERS. They are equal in authored
   markup; the packer changes only the first. */
/* The section number is counted, never typed. A train moved or added by the
   editor numbers itself, and so does one moved by hand in the source. */
.gal { padding: 0 0 var(--section-pad); counter-reset: train; }

.gal-train {
  display: grid;
  /* The column count, declared as well as applied. gallery.js reads this
     rather than counting the computed tracks: once it has written an inline
     span, a tile wider than the grid creates implicit tracks and the computed
     value no longer describes the breakpoint. Keep the two in step. */
  --gal-cols: 6;
  grid-template-columns: repeat(6, 1fr);
  gap: var(--gal-gap, 14px);
  counter-increment: train;
  margin-bottom: clamp(2rem, 4vw, 3.2rem);
  /* One height for every tile in the grid. It is set on the tile and not as
     grid-auto-rows, because the title band is a grid item too and has to size
     to its own text: a band a tile tall is a hole with a heading in it. */
  --gal-row: clamp(150px, 14vw, 230px);
}

/* The title band. One row tall, all six columns, hairline under it. */
.gal-train__head {
  grid-column: 1 / -1;
  display: flex; align-items: baseline; gap: 1rem;
  padding: .9rem .25rem;
  border-bottom: 1px solid var(--line);
}
.gal-train__n {
  flex: none;
  font-size: .66rem; letter-spacing: .2em; text-transform: uppercase;
  color: var(--accent); font-weight: 700;
}
/* The counter prints here. It is not hidden from assistive technology:
   generated content is part of the band's name, and the number is one of
   the two facts the band carries that nobody types. */
.gal-train__i { font-style: normal; }
.gal-train__i::before { content: counter(train, decimal-leading-zero) " / "; }
.gal-train__title { margin: 0; font-size: clamp(1.3rem, 2.4vw, 2rem); letter-spacing: -.02em; }
.gal-train__rule { flex: 1; height: 1px; background: var(--line); }
/* The year and the image count are one phrase, so they sit closer to each
   other than to the rule. The dot between them is drawn, not typed: the
   count is written at export and a separator in the markup would be too. */
.gal-train__meta {
  flex: none; display: flex; align-items: baseline; gap: .45rem;
  font-size: .72rem; color: var(--muted); letter-spacing: .05em;
}
.gal-train__count::before { content: "\00B7"; margin-right: .45rem; color: var(--dim); }

/* A tile. The figure is the slot; the image fills it. */
.gal-tile {
  position: relative; margin: 0; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--panel);
  height: var(--gal-row);
}
.gal-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Display True Pixel Size: the photo at its own size and centered, and
   contained when it is larger than the tile. gallery.js puts the carousel's
   blurred backdrop behind it, and the caption stays above both. */
.gal-tile img[data-truesize] { object-fit: scale-down; position: relative; z-index: 1; }
/* gallery.js marks a crisp photo is-shrunk while its tile is smaller than it */
.gal-tile img[data-crisp]:not(.is-shrunk) { image-rendering: pixelated; }
.gal-tile img.gal-tile__ambient {
  position: absolute; inset: -16%;
  width: 132%; height: 132%;
  object-fit: cover;
  z-index: 0;
  pointer-events: none;
  opacity: .46;
  filter: blur(34px) saturate(1.16) brightness(.72);
}
.gal-tile img[data-truesize] ~ .gal-tile__cap { z-index: 2; }
.gal-tile__cap {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 1.4rem .75rem .55rem;
  font-size: .72rem; color: #fff; letter-spacing: .01em;
  background: linear-gradient(to top, rgba(8, 10, 14, .86), rgba(8, 10, 14, 0));
}

/* The widths. Authored as an attribute rather than a class because the
   packer rewrites one value per tile, and a value is easier to be sure
   about than a set of classes that must not overlap. */
.gal-tile[data-span="1"] { grid-column: span 1; }
.gal-tile[data-span="2"] { grid-column: span 2; }
.gal-tile[data-span="3"] { grid-column: span 3; }
.gal-tile[data-span="4"] { grid-column: span 4; }
.gal-tile[data-span="6"] { grid-column: span 6; }

/* A tile opens the shared lightbox. gallery.js gives it the role and the
   tabindex; these are the two states that go with them. */
.gal-tile { cursor: zoom-in; }
.gal-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (hover: hover) {
  .gal-tile img { transition: transform .5s var(--ease); }
  .gal-tile:hover img { transform: scale(1.03); }
}
@media (prefers-reduced-motion: reduce) {
  .gal-tile img, .gal-tile:hover img { transition: none; transform: none; }
}

/* ------------------------------------------------------------
   THE COLLAPSE
   ------------------------------------------------------------
   These rules live here, beside the grid, and not in the RESPONSIVE
   section. A grid whose collapse is written somewhere else cannot be
   reasoned about, and this one has an invariant to keep. That is a
   deliberate exception to the one-place-for-responsive rule, and this
   comment is the record of it.

   Six columns cannot survive a phone, and the spans cannot be scaled:
   three columns have no room for an x4. So each width is mapped to a
   new one explicitly.

   Mapped widths do not add to the new column count the way the
   authored ones add to six, so the browser is asked to close the gaps
   with dense packing. That is the same job the packer does at full
   width, done by the layout engine, and it needs no script - which is
   what keeps the page correct with scripting off. */
@media (max-width: 880px) {
  .gal-train {
    --gal-cols: 3;
    grid-template-columns: repeat(3, 1fr);
    grid-auto-flow: dense;
    --gal-row: clamp(140px, 22vw, 200px);
  }
  .gal-tile[data-span="1"], .gal-tile[data-span="2"] { grid-column: span 1; }
  .gal-tile[data-span="3"] { grid-column: span 2; }
  .gal-tile[data-span="4"], .gal-tile[data-span="6"] { grid-column: span 3; }
}

@media (max-width: 560px) {
  .gal-train {
    --gal-cols: 1;
    grid-template-columns: 1fr;
    grid-auto-flow: row;
    --gal-row: clamp(180px, 52vw, 260px);
  }
  .gal-tile[data-span] { grid-column: 1 / -1; }
  .gal-train__head { flex-wrap: wrap; gap: .35rem .8rem; }
  .gal-train__rule { display: none; }
}

/* ============================================================
   4. OVERLAYS
   ------------------------------------------------------------
   The two surfaces that cover the page: the fullscreen lightbox
   and the deep-dive drawer. Both lock body scroll while open.
   ============================================================ */

/* ------------------------------------------------------------
   LIGHTBOX
   One reusable fullscreen viewer. Desktop: image fit to the
   page (no zoom). Touch devices: pinch-zoom + clamped pan + drag-to-change
   photos (added in the JS). Plus (X) close, prev/next arrows, a lower-left
   subtitle that glides in then idle-fades, and Esc / backdrop close. Sits
   above the header (z 1000).
   ------------------------------------------------------------ */
body.lb-open { overflow: hidden; }

.lightbox {
  position: fixed; inset: 0; z-index: 2000;
  background: rgba(4,6,10,.93);
  animation: lb-fade .25s var(--ease-soft) both;
}
.lightbox[hidden] { display: none; }
@keyframes lb-fade { from { opacity: 0; } to { opacity: 1; } }

/* the stage centers the image and reserves margins so the photo never
   collides with the close button, arrows, or caption bar. */
/* explicit inset rectangle (not padding) defines the safe area, so the photo
   never collides with the close button, arrows, or caption bar, and the box
   stays symmetric at any width. */
.lightbox__stage {
  position: absolute;
  top: clamp(48px, 8vh, 76px);
  right: clamp(44px, 8vw, 96px);
  bottom: clamp(52px, 9vh, 84px);
  left: clamp(44px, 8vw, 96px);
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  touch-action: none;       /* the JS owns touch gestures on mobile */
}
/* Two stacked layers: the active image, and an incoming layer revealed beneath
   it during a touch drag (so the lightbox slides photo-to-photo exactly like
   the main gallery). Both are absolutely centered + contained. */
.lightbox__img {
  position: absolute; inset: 0; margin: auto;       /* centered within the stage */
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;
  min-width: 0; min-height: 0;
  object-fit: contain;
  transform-origin: center center;
  will-change: transform;
  user-select: none; -webkit-user-select: none;
  -webkit-user-drag: none;
  filter: drop-shadow(0 22px 50px rgba(0,0,0,.55));
}
.lightbox__img--active   { z-index: 1; }
.lightbox__img--incoming { z-index: 0; opacity: 0; pointer-events: none; }   /* hidden until a drag reveals it */
.lightbox__img.is-zoomed { cursor: grab; }
/* a photo the engine marked crisp keeps square pixels, in a pinch as well */
.lightbox__img--crisp { image-rendering: pixelated; }

.lightbox__close,
.lightbox__nav {
  position: absolute; z-index: 2; padding: 0;
  display: grid; place-items: center;
  border: 1px solid rgba(255,255,255,.16);
  background: rgba(12,16,24,.6); color: #fff; cursor: pointer;
  border-radius: 50%;
  transition: background .25s, border-color .25s, transform .2s;
}
.lightbox__close:hover,
.lightbox__nav:hover { background: rgba(12,16,24,.95); border-color: var(--accent); }
.lightbox__close:focus-visible,
.lightbox__nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.lightbox__close { top: 16px; right: 18px; width: 46px; height: 46px; }
.lightbox__close svg { width: 20px; height: 20px; }
.lightbox__nav { top: 50%; transform: translateY(-50%); width: 50px; height: 50px; }
.lightbox__nav svg { width: 22px; height: 22px; }
.lightbox__nav--prev { left: 16px; }
.lightbox__nav--next { right: 16px; }
.lightbox__nav:hover { transform: translateY(-50%) scale(1.05); }

/* Editorial subtitle, mirroring the in-frame gallery caption: a quiet glass
   chip with an accent dot, anchored near the image's lower-left (not pinned to
   the very bottom). Hidden at rest; it glides in on an image change / mouse
   movement (desktop) or a drag (touch), then fades back out. */
.lightbox__caption {
  position: absolute;
  left: clamp(20px, 8vw, 96px);
  bottom: clamp(58px, 10vh, 96px);
  right: auto; top: auto;
  z-index: 3;
  max-width: min(72%, 540px);
  display: flex; align-items: center;
  padding: .5rem .9rem;
  border-left: 2px solid rgba(111,188,242,.78);
  border-radius: 0 8px 8px 0;
  background: linear-gradient(90deg, rgba(6,9,14,.86), rgba(6,9,14,.5) 76%, rgba(6,9,14,0));
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07), 0 18px 44px -30px rgba(0,0,0,.95);
  -webkit-backdrop-filter: blur(14px) saturate(1.14);
          backdrop-filter: blur(14px) saturate(1.14);
  color: rgba(235,238,244,.95);
  font-size: .9rem; font-weight: 700; letter-spacing: 0; line-height: 1.3;
  text-shadow: 0 1px 12px rgba(0,0,0,.5);
  pointer-events: none;
  opacity: 0; transform: translateY(8px);
  transition: opacity .6s var(--ease-soft), transform .6s var(--ease-soft);
}
.lightbox__caption::before {
  content: ""; flex: 0 0 auto;
  width: .46rem; height: .46rem; margin-right: .55rem;
  border-radius: 50%;
  background: var(--accent-bright);
  box-shadow: 0 0 18px rgba(74,165,232,.4);
}
.lightbox__caption.is-empty { display: none; }
.lightbox__caption.is-visible { opacity: 1; transform: translateY(0); }

/* See original: the file as it came, at the lower right, opposite the
   caption and at its size. It fades with the caption and returns on the
   same signals, and a keyboard focus shows it at once. A faded link takes
   no click, so an empty-looking corner never opens a tab. */
.lightbox__original {
  position: absolute;
  right: clamp(20px, 8vw, 96px);
  bottom: clamp(58px, 10vh, 96px);
  z-index: 3;
  max-width: min(40%, 320px);
  padding: .5rem .9rem;
  border-right: 2px solid rgba(111,188,242,.78);
  border-radius: 8px 0 0 8px;
  background: linear-gradient(270deg, rgba(6,9,14,.86), rgba(6,9,14,.5) 76%, rgba(6,9,14,0));
  -webkit-backdrop-filter: blur(14px) saturate(1.14);
          backdrop-filter: blur(14px) saturate(1.14);
  color: rgba(235,238,244,.95);
  font-size: .9rem; font-weight: 700; line-height: 1.3;
  text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  text-shadow: 0 1px 12px rgba(0,0,0,.5);
  pointer-events: none;
  opacity: 0; transform: translateY(8px);
  transition: opacity .6s var(--ease-soft), transform .6s var(--ease-soft), color .25s, border-color .25s;
}
.lightbox__original[hidden] { display: none; }
.lightbox__original.is-visible,
.lightbox__original:focus-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
.lightbox__original:hover { color: #fff; border-color: var(--accent-bright); }
.lightbox__original:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

@media (max-width: 560px) {
  .lightbox__close { width: 42px; height: 42px; top: 10px; right: 10px; }
  .lightbox__nav { width: 44px; height: 44px; }
  .lightbox__nav--prev { left: 8px; }
  .lightbox__nav--next { right: 8px; }
  .lightbox__caption {
    left: clamp(14px, 5vw, 28px);
    bottom: clamp(64px, 12vh, 96px);
    max-width: 80%;
    padding: .42rem .72rem;
    font-size: .8rem;
  }
  .lightbox__caption::before { width: .4rem; height: .4rem; margin-right: .46rem; }
  /* a phone has no room beside the caption, so the link takes the free
     corner opposite the close button */
  .lightbox__original {
    top: 10px; left: clamp(14px, 5vw, 28px); right: auto; bottom: auto;
    max-width: calc(100% - 90px);
    padding: .42rem .72rem;
    border-right: 0; border-left: 2px solid rgba(111,188,242,.78);
    border-radius: 0 8px 8px 0;
    background: linear-gradient(90deg, rgba(6,9,14,.86), rgba(6,9,14,.5) 76%, rgba(6,9,14,0));
    font-size: .8rem;
  }
}
@media (prefers-reduced-motion: reduce) {
  .lightbox { animation: none; }
  .lightbox__nav, .lightbox__close, .lightbox__caption, .lightbox__img, .lightbox__original { transition: none; }
}

/* ------------------------------------------------------------
   DEEP-DIVE DRAWER  ("Learn more")
   A right-side slide-over that swaps in a longer write-up for one
   project: a few paragraphs, single photos (each opens the shared
   lightbox on click / Enter for zoom), and bullet-point blocks.
   Built once in JS and filled from that project's
   <template class="deepdive" data-title data-subtitle>. Sits above
   the header (z 1000) and below the lightbox (z 2000), so a zoomed
   photo overlays the panel.

   AUTHORING a deep dive (all plain HTML, like the rest of the page):
     1. Drop a trigger button in the .project__body:
          <button class="project__more" type="button">Learn more …</button>
     2. Add a sibling template holding the expanded content:
          <template class="deepdive" data-title="…" data-subtitle="…">
            <p>…paragraph…</p>
            <figure class="dd-figure">
              <img src="…" alt="…" /><figcaption>…</figcaption>
            </figure>
            <h4>Section label</h4>
            <ul class="dd-points"><li>…</li></ul>
            <p class="dd-note">…quiet footer / attribution…</p>
          </template>
   A project with no trigger and no template has no drawer.
   ------------------------------------------------------------ */
body.dd-open { overflow: hidden; }

.dd {
  position: fixed; inset: 0; z-index: 1500;
  visibility: hidden;
  transition: visibility 0s linear .5s;   /* hold visible through the slide-out */
}
.dd.is-open { visibility: visible; transition: visibility 0s; }

.dd__scrim {
  position: absolute; inset: 0;
  background: rgba(4,6,10,.66);
  opacity: 0;
  transition: opacity .45s var(--ease-soft);
  -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
}
.dd.is-open .dd__scrim { opacity: 1; }

/* the panel slides in from the right */
.dd__panel {
  position: absolute; top: 0; right: 0; bottom: 0;
  width: min(560px, 100%);
  display: flex; flex-direction: column;
  background: var(--panel);
  border-left: 1px solid var(--line);
  box-shadow: -30px 0 80px -40px rgba(0,0,0,.9);
  transform: translateX(100%);
  transition: transform .5s var(--ease);
  will-change: transform;
}
.dd.is-open .dd__panel { transform: translateX(0); }

.dd__head {
  position: relative; flex: 0 0 auto;
  padding: clamp(1.6rem, 4vw, 2.4rem) clamp(1.4rem, 4vw, 2.4rem) 1.2rem;
  border-bottom: 1px solid var(--line-soft);
}
.dd__eyebrow {
  font-size: .72rem; font-weight: 800; letter-spacing: .22em;
  text-transform: uppercase; color: var(--accent);
}
.dd__title {
  margin-top: .6rem; padding-right: 3rem;   /* clear the close button */
  font-size: clamp(1.5rem, 3.4vw, 2rem);
  font-weight: 800; letter-spacing: -.02em; line-height: 1.12;
}
.dd__subtitle {
  margin-top: .5rem; color: var(--muted);
  font-size: .84rem; letter-spacing: .02em;
}
.dd__subtitle:empty { display: none; }

.dd__close {
  position: absolute; top: 1rem; right: 1rem;
  width: 42px; height: 42px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid var(--line); border-radius: 50%;
  background: rgba(12,16,24,.5); color: var(--text-soft); cursor: pointer;
  transition: background .25s, border-color .25s, color .25s;
}
.dd__close:hover { background: var(--panel-2); border-color: var(--accent); color: #fff; }
.dd__close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dd__close svg { width: 18px; height: 18px; }

/* scrollable body — the cloned template content lands here */
.dd__body {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  padding: clamp(1.4rem, 4vw, 2.4rem);
  -webkit-overflow-scrolling: touch;
}
.dd__body > * + * { margin-top: 1.15rem; }
.dd__body p { color: var(--text-soft); font-size: .96rem; line-height: 1.7; }
.dd__body h4 {
  font-size: .78rem; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: var(--dim); margin-top: 1.9rem;
}

/* a single photo; click / Enter opens the shared lightbox (zoom) */
figure.dd-figure { margin: 1.5rem 0 0; }
figure.dd-figure img {
  width: 100%; display: block; border-radius: var(--radius);
  border: 1px solid var(--line); cursor: zoom-in;
  transition: border-color .25s;
}
figure.dd-figure img:hover { border-color: rgba(74,165,232,.55); }
figure.dd-figure img:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
figure.dd-figure figcaption {
  margin-top: .55rem; color: var(--muted);
  font-size: .78rem; letter-spacing: .02em;
}
figure.dd-figure figcaption:empty { display: none; }

/* bullet block — mirrors the card .highlights diamond markers */
/* The renderer writes a plain <ul> and a heading at whichever level the
   heading tool stopped on, so the drawer styles all of them the same rather
   than asking the writer to remember which one it wants. */
.dd__body h2, .dd__body h3 {
  font-size: .78rem; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: var(--dim); margin-top: 1.9rem;
}
/* The title and the subtitle live inside the template now, because a
   splice writes what is between two markers and an attribute on the
   template is outside them. work.js lifts them into the drawer head and
   they are never shown in the body. */
.dd-lead, .dd-lead__sub { display: none; }
ul.dd-points, .dd__body ul { margin-top: 1.2rem; display: grid; gap: .6rem; list-style: none; padding: 0; }
ul.dd-points li, .dd__body ul li {
  position: relative; padding-left: 1.4rem;
  color: var(--text-soft); font-size: .92rem; line-height: 1.55;
}
ul.dd-points li::before, .dd__body ul li::before {
  content: ""; position: absolute; left: 0; top: .55em;
  width: 7px; height: 7px; border-radius: 2px; background: var(--accent);
  transform: rotate(45deg);
}

/* a quiet attribution / footer line inside a deep dive */
.dd-note {
  margin-top: 1.8rem; padding-top: 1.2rem;
  border-top: 1px solid var(--line-soft);
  color: var(--muted); font-size: .82rem; letter-spacing: .01em;
}

/* the card trigger button */
.project__more {
  margin-top: 1.7rem;
  display: inline-flex; align-items: center; gap: .5rem;
  padding: .58rem 1.1rem;
  font: inherit; font-size: .85rem; font-weight: 700; letter-spacing: .02em;
  color: var(--text); cursor: pointer;
  background: rgba(74,165,232,.08);
  border: 1px solid rgba(74,165,232,.35); border-radius: 999px;
  transition: background .25s, border-color .25s;
}
.project__more:hover { background: rgba(74,165,232,.16); border-color: var(--accent); }
.project__more:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.project__more svg { width: 15px; height: 15px; transition: transform .25s var(--ease); }
.project__more:hover svg { transform: translateX(3px); }

@media (max-width: 560px) {
  .dd__panel { width: 100%; border-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .dd, .dd__scrim, .dd__panel, .project__more svg { transition: none; }
}

/* ============================================================
   5. LATEST
   ------------------------------------------------------------
   The home page's list of the newest posts, which the composer
   writes at each publish.
   ============================================================ */

/* ------------------------------------------------------------
   LATEST FROM THE BLOG (home page)
   ------------------------------------------------------------
   The list itself is written by the publisher, so these rules have
   to hold for an empty block, for one post, and for five. */
.latest { padding: 0 0 clamp(3rem, 7vw, 6rem); }
.latest__list { margin-top: 1.4rem; border-top: 1px solid var(--line); }
.latest__item { display: flex; flex-wrap: wrap; align-items: baseline; gap: .35rem 1.1rem;
  padding: .85rem .2rem; border-bottom: 1px solid var(--line); color: var(--text);
  text-decoration: none; transition: color .18s ease, background .18s ease; }
.latest__item:hover, .latest__item:focus-visible { color: var(--accent-bright);
  background: rgba(74, 165, 232, .05); }
.latest__item time { flex: none; width: 13ch; color: var(--dim); font-size: .78rem;
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.latest__title { flex: 1 1 16ch; font-weight: 600; }
.latest__all { display: inline-block; margin-top: 1.1rem; }
.latest__empty { padding: 1.1rem .2rem; color: var(--muted); }

/* ============================================================
   6. REVEAL AND BLOG
   ------------------------------------------------------------
   The reveal a block makes as it scrolls into view, the blog
   stream, the month pages that share its design, and the players.
   ============================================================ */

/* ------------------------------------------------------------
   SCROLL REVEAL
   ------------------------------------------------------------ */
.reveal { opacity: 0; transform: translateY(34px); transition: opacity .9s var(--ease), transform .9s var(--ease); }
.reveal.in { opacity: 1; transform: none; }
.reveal.d1 { transition-delay: .08s; }
.reveal.d2 { transition-delay: .16s; }
.reveal.d3 { transition-delay: .24s; }

/* ------------------------------------------------------------
   BLOG - the stream, and the month pages that share its design.
   blog.html carries the newest month in full; a month page is the
   same column, the same posts, and the whole of each post.
   ------------------------------------------------------------ */

/* THE COLUMN. One measure, centred, with hairline sides on a wide
   screen so the stream reads as a column and not as a page of text.
   A month page uses the same two boxes, so the two are one design.

   The measure is 600px, and it is the box the bar sits in. The posts
   sit in a second box inside it that adds the hairline sides and their
   padding, which makes a post 550px on a wide screen.

   A month page adds its gutter OUTSIDE the measure. Taking the gutter
   out of the measure instead made a month page 504px wide against the
   stream's 550px, so the same post was narrower on its own page and the
   bar had 96px less room than the bar it copies. */
/* the outer box, which is the measure, and which the bar sits in */
.bs-stream { width: 100%; max-width: 600px; margin-inline: auto; }
.blog-wrap {
  width: 100%; margin-inline: auto;
  max-width: calc(600px + var(--gutter) * 2);
  padding-inline: var(--gutter);
  /* The site header is fixed, so the column starts below it. This uses the
     UNSCROLLED height: the header shrinks as you scroll, and a clearance
     that followed it would pull the page up by that much mid-scroll. */
  padding-block: calc(var(--header-top, 84px) + var(--gutter)) 0;
}
/* the inner box, which holds the posts. It draws no sides of its own:
   each post is a card, and the cards are the edges the eye follows. */
.bs-stream,
.blog-month main {
  margin-top: 1.2rem;
  padding-inline: clamp(0px, 3vw, 1.5rem);
}
/* The whole blog page is that column: the eyebrow and the heading above
   the bar line up with the posts under it, rather than running the width
   of the page while the stream sits narrow in the middle of them. */
.blog-page { padding-block: var(--section-pad); min-height: 46vh; }
.blog-page .wrap > * { max-width: 600px; margin-inline: auto; }
.blog-note {
  margin-top: 1.4rem; padding: 1rem 1.2rem;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--panel); color: var(--muted); font-size: .9rem;
}

/* THE BAR at the top of the column: the search box and the month picker,
   and nothing else. It carries no label of its own, because the site
   header names the site and the heading above names the month.

   Both controls sit at the right end, together, so the eye finds the two
   things it can act on in one place.

   It scrolls with the page. Only the site header is fixed, so the page has
   one thing pinned to the top and not two. It keeps a stacking context all
   the same: each post below is a card with a backdrop filter, which makes
   a stacking context of its own, and the search results would otherwise be
   painted over by the first card they reach. */
/* Search on the left, the month picker on the right. The two ends of the
   bar are the two jobs it does, and a reader looking for one is not
   reading past the other to reach it. The auto margin does it, so the row
   still collapses to one column when the bar wraps. */
.bs-bar__find { margin-right: auto; }
.bs-bar {
  position: relative; z-index: 5;
  display: flex; align-items: center; justify-content: flex-end;
  gap: .5rem .6rem; flex-wrap: wrap;
  margin-top: 1.4rem; padding: .6rem clamp(.5rem, 2vw, .9rem);
  border: 1px solid var(--line-soft); border-radius: 4px;
  /* the same ground the cards below it stand on, so the column reads as
     one surface rather than a strip of page above a list of cards */
  background-color: rgba(30, 34, 42, .55);
  background-image: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.012));
  backdrop-filter: saturate(130%) blur(10px);
  -webkit-backdrop-filter: saturate(130%) blur(10px);
}
/* The picker holds the longest month name, so it is the item that gives
   way on a phone. */
@media (max-width: 32rem) {
  .bs-picker__btn { max-width: 9rem; }
  .bs-picker__btn > :first-child { overflow: hidden; text-overflow: ellipsis; }
}

/* CARRY-OVER. A month file written before the bar became three items
   holds its month name and its stream link inside the bar. Both moved
   to the heading block above it. These two rules keep such a file
   readable until it is written again. Remove them with the .blog-post
   rules, once every month file has been rebuilt. */
.bs-bar__label {
  margin: 0; font-size: .82rem; font-weight: 600; letter-spacing: .06em; color: var(--muted);
}
.bs-bar__stream { font-size: .8rem; }

/* THE TWO CONTROLS IN THE BAR share one field style, so the search box and
   the month picker read as a pair rather than as a capsule beside a box.
   One shape, one height, one border, one type size, one focus.

   The picker is the browser's own <select>. That IS the standard dropdown,
   and it is the one a phone opens with its own picker, so only its box is
   styled. color-scheme is what makes the browser draw its open list dark,
   which is the part CSS cannot reach. */
.bs-find__pill,
.bs-picker__btn {
  box-sizing: border-box; height: 2rem;
  background: var(--bg-deep); color: var(--muted);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  font: 500 .78rem var(--font);
  transition: color .18s var(--ease-soft), border-color .18s var(--ease-soft),
              box-shadow .18s var(--ease-soft);
}
.bs-picker { position: relative; flex: none; }
.bs-picker__btn {
  display: inline-flex; align-items: center; gap: .35rem;
  padding: 0 .5rem; cursor: pointer; text-align: left;
}
.bs-picker__btn:hover { color: var(--text); border-color: var(--accent); }
.bs-picker__caret { width: 13px; height: 13px; flex: none; opacity: .8; }

/* THE LIST THE PAGE DRAWS. A native select's list is the operating
   system's, and CSS reaches none of it: on a dark page that left a light
   highlight band in the middle of the blog. This one is the page's, so it
   is the page's colours, the page's radius and the page's ground. */
.bs-picker__list {
  position: absolute; z-index: 30; top: calc(100% + .3rem); right: 0;
  min-width: 100%; max-height: 16rem; overflow-y: auto;
  margin: 0; padding: .25rem; list-style: none;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--radius-sm); box-shadow: 0 24px 60px -24px rgba(0,0,0,.9);
}
.bs-picker__opt {
  padding: .38rem .6rem; border-radius: 4px; white-space: nowrap;
  color: var(--text-soft); font: 500 .78rem var(--font); cursor: pointer;
}
.bs-picker__opt:hover,
.bs-picker__opt.is-at { background: var(--accent-faint); color: var(--text); }
.bs-picker__opt[aria-selected="true"] { color: var(--accent-bright); }
/* One focus treatment for both. The browser's own ring is replaced rather
   than removed: the border changes AND a soft halo appears, so focus stays
   plainly visible without a hard offset rectangle. */
.bs-find__pill:focus-within,
.bs-picker__btn:focus-visible {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-glow);
}

/* THE MONTH HEADING, above the bar on a month page only. It carries the
   eyebrow, the month as the page's h1, and the way back to the stream.
   These sit here rather than in the bar because the bar must hold one
   row at every width, and five items in a 600px measure cannot.
   It mirrors the eyebrow and heading blog.html puts above its own bar. */
.bm-top { margin-bottom: 1.2rem; }
.bm-top__month {
  margin: .5rem 0 0; font-size: clamp(1.5rem, 4vw, 2rem); line-height: 1.1;
}
.bm-top__stream { display: inline-block; margin-top: .5rem; font-size: .85rem; }

/* ONE POST ON ITS OWN, from "?post=pNNNN" on a month page.

   blog.js hides the other posts with the hidden property, so they leave
   the keyboard and screen-reader order as well as the screen. Nothing
   here hides a post: these rules only space the two ways out.

   The month keeps its bar, its header, and its contact block. A reader
   who wants the rest of the site must not have to press Back to find it. */
/* In this view the stream link is a way out to a place the reader did not
   ask about, and the rail below already carries this month. The nav still
   carries Blog. */
.is-focus .bm-top__stream { display: none; }

/* THE MONTH RAIL: every month the blog has, newest first, with the count
   each holds and the one being read marked.

   The publish writes it, because a month page carries no entries and so
   cannot count what it does not hold. That also means it works with no
   script, which the chain link at the foot of the page did not: that link
   only ever ran backward, and a reader on the oldest month had no way
   forward at all.

   The strip scrolls sideways and Newest is pinned outside it, so the one
   move a lost reader always wants cannot scroll out of reach. */
.bm-rail {
  display: flex; align-items: center; gap: .5rem;
  margin: .9rem 0 0;
}
.bm-rail__strip {
  display: flex; align-items: center; gap: .4rem;
  flex: 1; min-width: 0; margin: 0; padding: 0 0 .2rem; list-style: none;
  overflow-x: auto; scrollbar-width: thin;
  /* a chip is never sliced in half at the edge of the strip */
  scroll-snap-type: x proximity;
}
.bm-rail__strip > li { flex: none; scroll-snap-align: start; }
.bm-chip {
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .34rem .75rem; white-space: nowrap;
  border: 1px solid var(--line); border-radius: 999px;
  color: var(--accent-bright); font: 600 .74rem var(--font); text-decoration: none;
  transition: border-color .2s var(--ease), background .2s var(--ease),
              color .2s var(--ease);
}
.bm-chip:hover { border-color: var(--accent); background: var(--accent-faint); }
.bm-chip:focus-visible {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-glow);
}
/* the month being read: filled rather than outlined, so the reader finds
   their place in the strip before they read a single label */
.bm-chip.is-now {
  background: var(--panel-2); border-color: var(--line); color: var(--text);
}
.bm-chip__n {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.15rem; height: 1.15rem; padding: 0 .28rem;
  border-radius: 999px; background: rgba(255,255,255,.08);
  color: var(--muted); font-size: .62rem; font-weight: 700;
}
.bm-chip.is-now .bm-chip__n { background: var(--accent-faint); color: var(--accent-bright); }
/* Newest in the rail, and Newest and Newer at the foot: the moves that
   leave this month, filled so they are found before they are read. */
.bm-chip--newest,
.bm-chip--next {
  flex: none;
  background: var(--accent); border-color: var(--accent); color: var(--bg-deep);
}
.bm-chip--newest:hover,
.bm-chip--next:hover { background: var(--accent-bright); border-color: var(--accent-bright); }

/* Back sits under the bar, where the eye already is after the rail, and it
   is a filled chip because it is the one thing a reader in this view is
   most likely to want. The rail above it is outlined, so the two do not
   compete. It is a button, not a link, because it moves through history
   and points at no address.

   It must come AFTER .bm-chip: both are one class, so the later rule
   wins, and declared first it lost its colour and its border back to the
   outlined base. */
.bm-back { margin: .7rem 0 0; }
.bm-chip--back {
  background: var(--accent); border-color: var(--accent); color: var(--bg-deep);
  cursor: pointer;
}
.bm-chip--back:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
.bm-back__i { width: 14px; height: 14px; flex: none; }
/* Newest is live in every state, the newest month included, where it
   moves the reader to the newest post. It was inert there, and a control
   that is sometimes inert has to be read before it can be used. */

/* THIS VIEW OPENS AT THE TOP OF THE PAGE.

   The address keeps its "#p0007" so the page still reads with no script,
   which means the browser scrolls to that post. Here the post is the only
   one shown, and everything the reader needs above it, the post title,
   Back, View the whole month, the search box and the month picker, sits
   in the first 330px. Scrolling to the post throws all of that away.

   A scroll margin taller than any window makes the browser's own target
   position negative, so it clamps to the top of the page. Nothing is
   corrected after the fact and no script takes part, so there is no
   movement for the reader to see. A load handler cannot do this job: the
   browser scrolls to the fragment AFTER DOMContentLoaded, so a script
   that moved the page at startup would be undone a moment later. */
.is-focus main > .bs-post { scroll-margin-top: 100vh; }
.is-focus .bm-top__month { font-size: clamp(1.35rem, 3.4vw, 1.8rem); }

/* THE SEARCH PILL, in the bar's find slot, and the hits under it.
   The floor is what stops the pill collapsing: it is the only item in
   the bar that can give, so without a floor it shrank to an input 25px
   wide on a month page and the reader could not see what they typed. */
.bs-find {
  position: relative; flex: 1 1 7.5rem; min-width: 7.5rem;
  display: flex; justify-content: flex-end;
}
/* Read by a screen reader, not shown. The search box is an icon and a
   field, so without this its only name would be its placeholder, and a
   placeholder is not a name. */
.ced-sr {
  position: absolute; width: 1px; height: 1px; margin: -1px;
  padding: 0; border: 0; overflow: hidden; white-space: nowrap;
  clip-path: inset(50%);
}
.bs-find__pill {
  display: flex; align-items: center; gap: .45rem;
  width: min(240px, 100%); padding: 0 .6rem;
  cursor: text;
}
.bs-find__pill svg { width: 13px; height: 13px; flex: none; }
.bs-find__pill input {
  flex: 1; min-width: 0; background: none; border: 0; outline: none;
  color: var(--text); font: inherit;
}
.bs-find__pill input::placeholder { color: var(--dim); }
.bs-find__pill input:disabled { cursor: not-allowed; }
.bs-find__list {
  position: absolute; top: calc(100% + .4rem); right: 0; z-index: 20;
  width: min(360px, 100%); max-height: 22rem; overflow-y: auto;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--radius-sm); box-shadow: 0 24px 60px -24px rgba(0,0,0,.9);
}
.bs-find__count {
  padding: .45rem .7rem; border-bottom: 1px solid var(--line-soft);
  color: var(--dim); font-size: .7rem; letter-spacing: .06em; text-transform: uppercase;
}
.bs-find__hit {
  display: grid; grid-template-columns: 48px 1fr auto; column-gap: .6rem;
  padding: .55rem .7rem; border-bottom: 1px solid var(--line-soft);
  color: var(--text-soft);
}
.bs-find__hit:last-child { border-bottom: 0; }
.bs-find__hit:hover, .bs-find__hit.is-at { background: var(--accent-faint); }
.bs-find__thumb {
  grid-row: span 2; width: 48px; height: 48px; object-fit: cover;
  border-radius: 6px; border: 1px solid var(--line); background: var(--bg-deep);
}
.bs-find__thumb--text {
  display: flex; align-items: center; justify-content: center;
  font: 700 .6rem Consolas, monospace; color: var(--dim); letter-spacing: .08em;
}
.bs-find__hit b { grid-column: 2; font-size: .82rem; color: var(--text); }
.bs-find__hit small { grid-column: 3; color: var(--dim); font-size: .7rem; white-space: nowrap; }
.bs-find__hit p {
  grid-column: 2 / -1; margin: .15rem 0 0; font-size: .74rem; line-height: 1.5; color: var(--muted);
}
.bs-find__hit mark { background: rgba(74,165,232,.28); color: var(--text); border-radius: 3px; }

/* THE FILTER LINE, under the bar while one tag is showing. */
/* THE HEADING OF A TAG VIEW. The reader asked a question with a tag, and
   this is that question above the answer, in the words they asked it in.

   It is a heading and not a status line: it names what is on screen, and
   the page has no other name for this state. Back sits under it, so the
   column reads question, way out, then answer. */
.bs-results {
  margin: 1.2rem 0 0;
  font-size: clamp(1.15rem, 3vw, 1.5rem); font-weight: 700;
  letter-spacing: -.01em; color: var(--text);
}
.bs-results b { color: var(--accent-bright); font-weight: 700; }
.bs-showing {
  margin: .6rem 0 0; font-size: .78rem; color: var(--muted);
  display: flex; align-items: center; gap: .3rem; flex-wrap: wrap;
}
.bs-showing b { color: var(--accent-bright); font-weight: 700; }
.bs-showing__clear {
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--dim); font: inherit; text-decoration: underline;
}
.bs-showing__clear:hover { color: var(--text); }
/* the posts with that tag that this page does not hold, under the stream */
.bs-tagrest { position: static; width: 100%; max-height: none; margin-top: 1rem; }

/* ONE POST. A hairline between posts, the byline, then the words. */
/* A POST IS A CARD. Each one sits on its own ground, so the eye reads a
   list of things rather than one wall of text divided by rules.

   The ground is translucent and blurred, so the page's own background
   shows through softened rather than being covered. The gradient is
   almost nothing on purpose: enough to give the card a top and a bottom,
   not enough to read as a colour. */
/* A POST IS A FRAGMENT TARGET. "#p0007" is how every shared link to a
   post is written, so the browser scrolls a post to the top of the
   window, which is under the fixed header. This is the clearance.

   It reads the UNSCROLLED header height, for the reason .blog-wrap gives:
   the header loses .6rem when it shrinks, and a clearance that followed
   that would move the landing place mid-scroll. The 84px fallback is what
   a page with no script uses, because nothing publishes the real height
   there. */
.bs-post {
  scroll-margin-top: calc(var(--header-top, 84px) + .75rem);
  margin-block: clamp(.7rem, 2vw, 1rem);
  padding: clamp(1.1rem, 3vw, 1.6rem);
  border: 1px solid var(--line-soft);
  border-radius: 4px;
  background-color: rgba(30, 34, 42, .55);
  background-image: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.012));
  backdrop-filter: saturate(130%) blur(10px);
  -webkit-backdrop-filter: saturate(130%) blur(10px);
  transition: border-color .2s var(--ease-soft);
}
.bs-post:hover { border-color: var(--line); }
/* the post a deep link named: the accent is on the edge, so the card's
   own ground is not repainted under the words */
.bs-post.is-target {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent), 0 0 24px -6px var(--accent);
}
.bs-post__by {
  display: grid; grid-template-columns: 40px 1fr auto; align-items: center;
  column-gap: .7rem; row-gap: .1rem;
}
.bs-post__avatar {
  grid-row: span 2; width: 40px; height: 40px; border-radius: 50%;
  object-fit: cover; object-position: top center;
  background: var(--bg-deep); border: 1px solid var(--line);
}
.bs-post__by b {
  grid-column: 2; font-size: .88rem; font-weight: 700; letter-spacing: .02em; color: var(--text);
}
.bs-post__when {
  grid-column: 2; display: inline-flex; align-items: center; gap: .45rem;
  color: var(--dim); font-size: .76rem; font-variant-numeric: tabular-nums;
}
a.bs-post__when:hover { color: var(--accent-bright); }
.bs-post__zone {
  border: 1px solid var(--line); border-radius: 999px; padding: 0 6px;
  font-size: .62rem; letter-spacing: .08em; text-transform: uppercase;
}
.bs-post .bs-retry { grid-column: 3; grid-row: span 2; }

/* A post that is built and not yet uploaded says so, and the chip is the
   way back to the list of what is left to do with it. */
.bs-post--staged { border-left: 2px solid var(--c-yellow, #f0b429); padding-left: .8rem; }
.bs-post__staged {
  grid-column: 3; justify-self: end;
  padding: .1rem .55rem; border-radius: 999px; cursor: pointer;
  background: none; border: 1px solid rgba(240,180,41,.5); color: var(--c-yellow, #f0b429);
  font: 600 .64rem var(--font); letter-spacing: .04em;
}
.bs-post__staged:hover { border-color: var(--c-yellow, #f0b429); }

/* THE WORDS. The Markdown set the renderer emits, and nothing else. */
.bs-post__title {
  margin: .9rem 0 0; font-size: clamp(1.1rem, 2vw, 1.35rem);
  font-weight: 800; letter-spacing: -.01em; line-height: 1.25; color: var(--text);
}
/* The title is the way into the post, so it is a link. It keeps the
   heading's own color: a stream of link-blue headings would read as a
   list of links rather than a page of posts. The underline on hover and
   on keyboard focus is what says it can be followed. */
.bs-post__title a { color: inherit; text-decoration: none; }
.bs-post__title a:hover { text-decoration: underline; }
.bs-post__title a:focus-visible { text-decoration: underline; outline: none; }
.bs-post__body { margin-top: .7rem; color: var(--text-soft); line-height: 1.7; }
.bs-post__body > * + * { margin-top: .9rem; }
.bs-post__body h2, .bs-post__body h3, .bs-post__body h4 {
  margin-top: 1.6rem; font-weight: 800; letter-spacing: .01em; color: var(--text);
}
.bs-post__body h2 { font-size: 1.15rem; }
.bs-post__body h3 { font-size: 1.05rem; }
.bs-post__body h4 { font-size: .96rem; }
.bs-post__body a:not(.textlink) { color: var(--accent); }
.bs-post__body ul, .bs-post__body ol { padding-left: 1.3rem; }
.bs-post__body li + li { margin-top: .3rem; }
.bs-post__body ul { list-style: disc; }
.bs-post__body ol { list-style: decimal; }
.bs-post__body blockquote {
  padding-left: .9rem; border-left: 2px solid var(--accent);
  color: var(--muted); font-style: italic;
}
.bs-post__body blockquote p { margin: 0; }
.bs-post__body pre {
  padding: .8rem .9rem; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--bg-deep); overflow-x: auto;
}
.bs-post__body code {
  font: .86em Consolas, "Courier New", monospace; color: var(--accent-bright);
}
.bs-post__body pre code { color: var(--text-soft); }
.bs-post__body hr { border: 0; border-top: 1px solid var(--line-soft); }
.bs-post__body table {
  width: 100%; border-collapse: collapse; font-size: .88rem; display: block; overflow-x: auto;
}
.bs-post__body th, .bs-post__body td {
  padding: .4rem .6rem; border: 1px solid var(--line-soft); text-align: left;
}
.bs-post__body th { color: var(--text); font-weight: 700; background: var(--bg-deep); }
.bs-post__body s { color: var(--dim); }

/* THE TAGS. */
.bs-post__tags { margin-top: .9rem; display: flex; flex-wrap: wrap; gap: .45rem; }
.bs-post__tags a {
  padding: .15rem .6rem; border: 1px solid var(--line); border-radius: 999px;
  color: var(--dim); font-size: .73rem; letter-spacing: .02em;
}
.bs-post__tags a:hover { border-color: var(--accent); color: var(--accent-bright); }

/* THE FOLD WINS OVER THE BLOCK IT HIDES.

   A cut hides each block past it with the hidden property, and the
   browser's own rule is what draws that as display:none. Every rule in
   this file beats a browser rule, whatever its specificity, so a block
   that states a display of its own stayed on screen inside a folded post:
   a carousel, which is display:block once it is ready, showed its
   photographs under a post that was closed.

   This says it once for every block a post can hold, now and later, which
   is why it is important and why it is not written for the carousel
   alone. The blocks a cut hides are the children of the body, so that is
   what it covers.

   A hidden image is also an image the browser does not fetch: a post
   writes every photograph with loading="lazy", and a lazy image with no
   box on the page is never near the viewport. So a folded post costs its
   reader nothing until they open it. */
.bs-post__body > [hidden] { display: none !important; }

/* THE CUT CONTROLS. A long post shows to here and opens in place. */
.bs-more {
  display: inline-flex; align-items: center; gap: .4rem;
  margin-top: .9rem; padding: .35rem .9rem;
  background: transparent; color: var(--accent-bright);
  border: 1px solid var(--line); border-radius: 999px;
  font: 600 .78rem var(--font); cursor: pointer;
  transition: border-color .25s var(--ease), color .25s var(--ease);
  /* Expand for more is a button and Read more is a link to the post's own
     page. They are one control to the reader, so the pill covers both and
     the link gives up its underline to match. */
  text-decoration: none;
}
.bs-more:hover { border-color: var(--accent); }
.bs-more::after { content: "\2193"; }
.bs-more[data-more="hard"]::after { content: "\2192"; }
.bs-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* THE WAY TO THE MONTH BEFORE, at the foot of the page, and the divider
   an appended month gets in the stream. The divider carries the top border
   the first post of a month would otherwise lack, because an appended
   month's first post is no longer the first child.

   On a month page this link navigates and is a chip, so it reads as a
   control and not as a line of text after the last post. In the stream it
   still appends, and keeps the plain look that says it expands in place.

   These rules come after .bm-chip on purpose: both are one class, so the
   later one wins, and the chip look would be lost if they came first. */
.bm-older {
  display: inline-flex; align-items: center; gap: .4rem;
  margin: 1.6rem 0 0; padding: .4rem .85rem;
  border: 1px solid var(--accent); border-radius: 999px;
  background: var(--accent); color: var(--bg-deep);
  font: 600 .78rem var(--font); text-decoration: none;
  transition: background .2s var(--ease), border-color .2s var(--ease);
}
.bm-older:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
.bm-older:focus-visible {
  outline: none; box-shadow: 0 0 0 3px var(--accent-glow);
}
.bm-older__i { width: 15px; height: 15px; flex: none; }
/* The display above beats the browser's own rule for [hidden], so the
   focused view could set hidden on this link and still leave it on
   screen. Any element that declares display needs this to stay hidable. */
.bm-older[hidden] { display: none; }
.bm-older[aria-busy="true"] {
  background: none; border-color: var(--line); color: var(--dim); pointer-events: none;
}
/* The stream's link is the same chip, because every other way through
   the blog is one and a plain line of text there read as an afterthought.
   What it does is still different: it expands in place rather than
   navigating, and its arrow points down to say so. */
/* The end of the chain is a statement, not a control. */
.bm-older--end,
#blogStream .bm-older--end {
  display: block; padding: 0; border: 0; background: none;
  color: var(--dim); font-size: .88rem; font-weight: 400;
}

/* THE MONTH CHAIN, at the foot: the ways on, under the last post.

   The rail is nearly three screens above by the time a reader reaches
   this, so these are the only controls they can see. They are filled for
   that reason: nothing else competes at the end of a page, and an
   outlined chip there reads as a line of text.

   blog.js puts Back at the head of the row when this site sent the reader
   here, so the row is written even when it holds nothing. An empty row
   has no children and takes no height. */
.bm-chain { margin: 1.6rem 0 0; }
.bm-chain__row {
  display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
}
/* The older link carries its own top margin for the stream, where it
   stands alone under the posts. In the row the row owns the spacing. */
.bm-chain .bm-older { margin: 0; }
.bm-chain__i { width: 14px; height: 14px; flex: none; }
/* The oldest month says so, above the ways out and not instead of them. */
.bm-chain__end {
  margin: 0 0 .7rem; color: var(--dim); font-size: .88rem;
}
.bm-divider {
  margin: clamp(1.6rem, 4vw, 2.4rem) 0 0; padding-top: 1.2rem;
  border-top: 1px solid var(--line-soft);
  font-size: .8rem; font-weight: 700; letter-spacing: .14em;
  text-transform: uppercase; color: var(--accent);
}
.bm-divider:focus { outline: none; }
.bm-divider + .blog-post { border-top: 0; }


/* A post's images are carousels, one for each run of tags, and work.js
   draws them as it draws the home page's. A cut flag's marker is read by
   the fold and seen by nobody. */
.bs-post__body .gallery { margin: 1.1rem 0 0; }
.bp-cut { display: none; }

/* A FRAME WITH NO BORDERS. noborders on a carousel's first tag takes away
   the frame's border, its ground, its shadow, its glow, its inset, the
   rounding of what it shows and the caption's plate. The frame keeps its
   shape, its caption and its arrows, and the arrows keep their focus
   ring. */
.gallery[data-noborders] .gallery__frame {
  border: 0; border-radius: 0; background: none; box-shadow: none;
}
.gallery[data-noborders] .gallery__frame::before,
.gallery[data-noborders] .gallery__frame::after,
.gallery[data-noborders] .gallery__ambient { display: none; }
.gallery[data-noborders] .gallery__stage { inset: 0; border-radius: 0; }
.gallery[data-noborders] .gallery__img,
.gallery[data-noborders] .gallery__media video { border-radius: 0; filter: none; }
.gallery[data-noborders] .gallery__caption {
  background: none; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none;
}

/* A MEDIA FIGURE. A tag on its own line with nocarousel, and a video, a
   sound or a MIDI file, is a figure. The browser's own player does the
   work, and it works with no script: these first rules keep each figure
   inside the column, no picture or video wider than the post or taller
   than most of the screen, with the caption and the file link under it.
   work.js puts a player in a box of its own, .bp-media__box, and puts its
   buttons over it. */
.bs-post__body .bp-media { margin: 1.1rem 0 0; }
.bp-media > img, .bp-media > video, .bp-media__box > video {
  display: block; max-width: 100%; height: auto; max-height: 85vh; margin: 0 auto;
}
.bp-media > video, .bp-media__box > video { width: 100%; background: #000; }
.bp-media > audio, .bp-media__box > audio { display: block; width: 100%; }
.bp-media__box { position: relative; }
.bp-media__cap { margin-top: .45rem; color: var(--muted); font-size: .85rem; line-height: 1.5; }
.bp-media__file { display: inline-block; margin-top: .35rem; font-size: .8rem; }

/* A FIGURE OF ITS OWN. A photo or a video on its own has a thin border and
   a small gap to its caption. With a frame word it takes that frame, as
   --bp-ratio from work.js, and what it shows is contained in it, never
   stretched or cut. Without a word a photo keeps its own size, and a
   video its own shape, 16:9 until its size is known. The width keeps the
   frame within the screen's height, as a carousel's does. A sound stays
   one player high whatever its words, and a MIDI file is a card with its
   file link. */
.bp-media--alone > img,
.bp-media--alone[data-kind="video"] .bp-media__box {
  border: 1px solid var(--line); border-radius: var(--radius); background: #06080c; overflow: hidden;
}
.bp-media--alone[data-kind="video"] .bp-media__box,
.bp-media--alone[data-shape] > img {
  width: 100%;
  width: min(100%, calc(var(--gallery-screen, .85) * 100vh * var(--bp-ratio, 1.7778)));
  width: min(100%, calc(var(--gallery-screen, .85) * 100svh * var(--bp-ratio, 1.7778)));
  margin-inline: auto;
}
.bp-media--alone[data-kind="video"] .bp-media__box { aspect-ratio: var(--bp-ratio, 16 / 9); }
.bp-media--alone .bp-media__box > video {
  width: 100%; height: 100%; max-height: none; object-fit: contain; background: none;
}
.bp-media--alone[data-shape] > img {
  aspect-ratio: var(--bp-ratio); height: auto; max-height: none; object-fit: contain;
}
.bp-media__photo { cursor: zoom-in; }
.bp-media__photo:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.bp-media--alone[data-kind="midi"] {
  display: flex; flex-wrap: wrap; align-items: center; gap: .3rem .8rem;
  padding: .7rem .9rem; border: 1px solid var(--line); border-radius: var(--radius);
}
.bp-media--alone[data-kind="midi"]::before {
  content: "\266B"; display: grid; place-items: center; flex: none;
  width: 2.2rem; height: 2.2rem; border-radius: 50%;
  background: rgba(255,255,255,.06); color: var(--accent-bright); font-size: 1.05rem;
}
.bp-media--alone[data-kind="midi"] .bp-media__cap { flex: 1 1 10rem; margin: 0; }
.bp-media--alone[data-kind="midi"] .bp-media__file { margin: 0; }
.bp-media--alone[data-noborders] > img,
.bp-media--alone[data-noborders] .bp-media__box,
.bp-media--alone[data-noborders][data-kind="midi"] {
  border: 0; border-radius: 0; background: none;
}

/* A PLAYER ON A SLIDE. A video or a sound in a carousel is a slide as a
   photo is: it fills the stage and cross-fades. A slide that is not on
   screen is hidden from the pointer and the keyboard as well as the eye,
   because a player's controls still answer at opacity 0. The caption is
   the strip's, and a video's file link shows when the browser cannot
   play the file. */
.gallery__media {
  position: absolute; inset: 0; margin: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .7rem;
  opacity: 0; visibility: hidden;
  transition: opacity .46s var(--ease), visibility 0s linear .46s;
  cursor: default;
}
.gallery__media.is-active { opacity: 1; visibility: visible; transition: opacity .46s var(--ease), visibility 0s; }
.gallery__holder.is-media { cursor: default; }
.gallery__media > .bp-media__cap { display: none; }
.gallery__media[data-kind="video"] .bp-media__box { width: 100%; height: 100%; }
.gallery__media video {
  width: 100%; height: 100%; max-height: none; object-fit: contain; background: none; border-radius: 6px;
}
.gallery__media[data-kind="video"] > .bp-media__file { display: none; }
.gallery__media[data-kind="video"][data-state="error"] > .bp-media__file {
  display: inline-block; position: absolute; left: 50%; bottom: 1rem; z-index: 3; margin: 0;
  transform: translateX(-50%); padding: .3rem .7rem; border-radius: 999px; background: rgba(6,9,14,.82);
}

/* A SOUND OR A MIDI FILE ON A SLIDE is a card in the middle of the frame:
   a mark for its kind, then its player or its file link. It has no
   picture, so the photos and the videos around it give the frame its
   shape. */
.gallery__media[data-kind="audio"],
.gallery__media[data-kind="midi"] { padding: 1rem 1.2rem; text-align: center; }
.gallery__media[data-kind="audio"]::before,
.gallery__media[data-kind="midi"]::before {
  content: "\266A"; display: grid; place-items: center; flex: none;
  width: 3.2rem; height: 3.2rem; border-radius: 50%;
  border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.06);
  color: var(--accent-bright); font-size: 1.5rem;
}
.gallery__media[data-kind="midi"]::before { content: "\266B"; }
.gallery__media[data-kind="audio"] .bp-media__box { width: min(100%, 28rem); }

/* THE BUTTONS ON A PLAYER. Play sits in the middle of a paused video,
   away from the browser's own controls at the foot, and goes when the
   video plays. It says Play in words when the browser refused to start
   the video by itself. A video with no controls keeps a Pause button at
   its lower left while it plays, and a sound with no controls is a bar
   with its name and one Play or Pause button. The line under a player
   says a refusal or an error. */
.bp-play, .bp-pause, .bp-audio__btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
  border: 1px solid rgba(255,255,255,.28); border-radius: 999px;
  background: rgba(6,9,14,.74); color: #fff; cursor: pointer;
}
.bp-play svg, .bp-pause svg, .bp-audio__btn svg { width: 1.1rem; height: 1.1rem; flex: none; }
.bp-play {
  position: absolute; left: 50%; top: 50%; z-index: 3; transform: translate(-50%, -50%);
  min-width: 3rem; height: 3rem; padding: 0 .95rem;
}
.bp-play.is-waiting { opacity: .7; cursor: progress; }
.bp-play__say { display: none; font-size: .8rem; font-weight: 700; letter-spacing: .02em; }
[data-state="blocked"] .bp-play__say { display: inline; }
.bp-pause { position: absolute; left: .7rem; bottom: .7rem; z-index: 3; width: 2.5rem; height: 2.5rem; }
.bp-play[hidden], .bp-pause[hidden] { display: none; }
.bp-play:focus-visible, .bp-pause:focus-visible, .bp-audio__btn:focus-visible {
  outline: 2px solid var(--accent-bright); outline-offset: 3px;
}
.bp-audio {
  display: flex; align-items: center; gap: .7rem; min-width: 0;
  padding: .45rem .9rem .45rem .45rem; border: 1px solid var(--line); border-radius: 999px;
  background: rgba(255,255,255,.04);
}
.bp-audio__btn { width: 2.3rem; height: 2.3rem; flex: none; }
.bp-audio__name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--text); font-size: .85rem;
}
.bp-media__say { margin: .45rem 0 0; color: var(--muted); font-size: .8rem; line-height: 1.45; }
.bp-media__say[hidden] { display: none; }
.gallery__media .bp-media__say {
  position: absolute; left: .7rem; right: .7rem; top: .7rem; z-index: 3; margin: 0;
  padding: .35rem .6rem; border-radius: 8px; background: rgba(6,9,14,.82); color: var(--text);
}

/* The editor's New post pills: one at the end of the bar, after the month
   picker, and one centered under the last post. Two classes, because the
   editor's pill paint is injected after this file and sets its display. */
.bs-newpost.bs-newpost--foot { display: flex; width: fit-content; margin: 1.4rem auto 0; }

/* Shared small parts: the note when a target is not found, and the
   editor's own button on a post. */
.bs-note {
  margin-top: 2rem; padding: 1rem 1.2rem;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--panel); color: var(--muted); font-size: .9rem;
  display: flex; align-items: center; gap: .9rem; flex-wrap: wrap;
}
/* THE POST EDIT CHIP. Solid yellow, black text, with a pencil at its left.

   The editor puts two kinds of chip on a page and they do different jobs.
   A copy chip is a small hollow BLUE circle and it edits one piece of text.
   This one is a solid YELLOW pill and it opens the whole post. Filled
   against hollow, and yellow against blue, so the two can be told apart at
   a glance rather than by reading them. Yellow is the editor's own colour
   for blog work, which the panel already uses.

   THE PAINT IS NOT HERE. A pill exists only while the editor is on, and the
   gallery's own pills wear the same one, so it lives in the editor's
   stylesheet as .ced-pill and this file keeps only the placement. blog.js
   puts both classes on the button. */
.bs-retry__i { width: 13px; height: 13px; flex: none; }

/* MONTH FILES WRITTEN BEFORE V051 keep their own post markup until one
   rebuild gives them the design above. These rules go with that rebuild. */
.blog-post { padding: clamp(1.4rem, 3.5vw, 2.2rem) 0; border-top: 1px solid var(--line-soft); }
.blog-post:first-of-type { border-top: 0; }
.blog-post > header h2 {
  font-size: clamp(1.2rem, 2.4vw, 1.5rem);
  font-weight: 800; letter-spacing: -.02em; line-height: 1.2;
}
.blog-post > header time {
  display: block; margin-top: .35rem;
  font-size: .76rem; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--accent);
}
.blog-post__body { margin-top: 1rem; color: var(--text-soft); line-height: 1.7; }
.blog-post__body > * + * { margin-top: .9rem; }
.blog-post__body h3 { margin-top: 1.6rem; font-size: 1.05rem; font-weight: 800; color: var(--text); }
.blog-post__body a:not(.textlink) { color: var(--accent); }
