/**
 * Layout — the app shell, the article list, and the article detail frame.
 *
 * Mobile-first throughout. The few media queries here change *structure* (a
 * grid gaining columns, a hero splitting in two); every size and gap comes from
 * the token scale and grows on its own.
 */

.skip-link {
  position: absolute;
  left: var(--sp-4);
  top: calc(var(--sp-4) * -4);
  z-index: var(--z-modal);
  padding: var(--sp-3) var(--sp-4);
  border-radius: var(--r-md);
  background: var(--surface-2);
  color: var(--text);
  transition: top var(--dur) var(--ease);
}

.skip-link:focus { top: var(--sp-4); }

.noscript {
  max-width: var(--w-reading);
  margin-inline: auto;
  padding: var(--sp-12) var(--gutter);
}

/* ------------------------------------------------------------------ topbar */

/**
 * The top bar — in the flow, opaque, and bounded.
 *
 * NOT STICKY.
 *   It used to pin to the top of the viewport with a translucent background and
 *   `backdrop-filter`. Both are gone, and for the same reason: a blurred pane is
 *   a full-width, full-time compositing job that the browser re-rasterises
 *   against whatever is scrolling underneath it, on every frame of every scroll.
 *   On a phone that is the most expensive thing on the page, and it was being
 *   paid to keep a brand mark and one bell on screen — furniture, not content.
 *   It also put a permanently-composited layer directly above the sticky
 *   assistant card, which is the pairing that made the sheet's transition
 *   stutter: two stacked layers, both invalidated by the same scroll.
 *
 *   Scrolled away, the article gets the whole viewport, and the reading progress
 *   rail is what stays behind to say where you are.
 *
 * IT MATCHES THE BACKGROUND, IT DOES NOT SIT ON IT.
 *   `--topbar-bg` is `--bg`: the same value the page uses, not a translucent
 *   approximation of it. There is nothing to see through and nothing to blur, so
 *   the bar reads as the top of the page rather than as a pane over it.
 *
 * IT IS AS WIDE AS THE PAGE UNDER IT.
 *   Capped at the route's own measure rather than bled to the window, so the
 *   brand and the bell land at the edges of the COLUMN. Full-width, a 1440px
 *   viewport put half a screen of empty space between the two, over a body of
 *   text half that wide.
 *
 * THE BOUNDARY IS DELIBERATE.
 *   A hairline under the bar, plus real space beneath it. With nothing pinned any
 *   more, that line is the only thing separating the chrome from the article — so
 *   it is drawn as a divider between two things, which is what it is, instead of
 *   as the underside of a banner.
 */
.topbar {
  --topbar-bg: var(--bg);
  --topbar-radius: 0;
  /* The width follows the route: the reading measure by default, the wide
     container on the list and the preset gallery. */
  --topbar-measure: var(--w-reading);

  position: relative;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  width: min(var(--topbar-measure), calc(100% - 2 * var(--sp-3)));
  margin: var(--sp-3) auto var(--sp-4);
  padding: var(--sp-3) 0;
  border-radius: var(--topbar-radius);
  background: var(--topbar-bg);
}

/* The wide routes lay out on the wide container; the bar grows to match so it
   is never inset over a wider grid. `data-route` is set in app.ts. */
:root[data-route="list"] .topbar,
:root[data-route="presets"] .topbar {
  --topbar-measure: var(--w-wide);
}

.topbar::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 1px;
  background: var(--line-soft);
}

/*
 * The brand cluster: two links that go to two different places.
 *
 * It is a plain container now, not an anchor. The logo goes to the main site and
 * "Artikel" goes to this app's index — see ui/topbar.ts for why each one is a
 * real `<a href>`. The `/` between them is punctuation, hidden from the
 * accessibility tree, and it is what makes the pair read as one path rather than
 * as two unrelated buttons.
 */
.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  color: var(--text-bright);
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-tight);
}

.brand__home,
.brand__index {
  display: inline-flex;
  align-items: center;
  color: inherit;
  transition: opacity var(--dur-fast) var(--ease);
}

.brand__home:hover,
.brand__index:hover { text-decoration: none; opacity: 0.85; }

/* Focus rings come from the global `:focus-visible` in 10-base.css — two links
   where there was one means two focus stops, and they are already drawn. */

.brand__sep { font-weight: var(--fw-semi); }

.brand__index { font-size: var(--fs-sm); }

/*
 * The logo, sized by height.
 *
 * `width: auto` against a fixed height keeps the mark's own proportions whatever
 * the file is, so replacing the logo never squashes it. The `width`/`height`
 * attributes on the element give the browser the ratio up front, so the header
 * reserves the right box before the image loads and nothing shifts.
 */
.brand__logo {
  height: 26px;
  width: auto;
  display: block;
}

@media (min-width: 40rem) {
  .brand__logo { height: 30px; }
}

.topbar__actions {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin-left: auto;
}

/* Anchor for a popover; the popover positions against this. */
.topbar__slot { position: relative; }

/*
 * The bell.
 *
 * Brand blue, like the rest of the interface. It was green — the argument being
 * that a signal must not share the accent colour of the furniture — but the
 * badge is what carries the signal, and one green control in an otherwise blue
 * header read as a stray element rather than as an alert.
 */
.bell {
  position: relative;
  color: var(--blue-bright);
  border-radius: var(--r-md);
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
    transform var(--dur-fast) var(--ease);
}
.bell:hover { color: var(--blue-bright); background: rgba(47, 111, 208, 0.14); transform: translateY(-1px); }

/* A gentle nudge on arrival, once, and never for reduced-motion readers. */
.bell.has-new svg { animation: bell-nudge 2.4s var(--ease) 1; transform-origin: 50% 15%; }

@media (prefers-reduced-motion: reduce) {
  .bell.has-new svg { animation: none; }
  .bell.has-new .bell__count { animation: none; }
}

@keyframes bell-nudge {
  0%, 70%, 100% { rotate: 0deg; }
  76% { rotate: 9deg; }
  82% { rotate: -7deg; }
  88% { rotate: 4deg; }
}

/* `display: grid` below would beat the UA's `[hidden]` rule, so say it here. */
.bell__count[hidden] { display: none; }

/* Always a single digit — the bell announces one article, never a total.
   While it is new, a soft ring breathes around it so the eye finds it once. */
.bell__count {
  position: absolute;
  top: 3px;
  right: 3px;
  display: grid;
  place-items: center;
  min-width: 17px;
  height: 17px;
  padding: 0 4px;
  border: 2px solid var(--bg);
  border-radius: var(--r-pill);
  background: var(--blue);
  color: #fff;
  font-size: var(--fs-2xs);
  font-weight: var(--fw-black);
  line-height: 1;
}

.bell.has-new .bell__count { animation: bell-pulse 2.6s var(--ease) 2; }

@keyframes bell-pulse {
  0% { box-shadow: 0 0 0 0 rgba(47, 111, 208, 0.5); }
  70% { box-shadow: 0 0 0 7px rgba(47, 111, 208, 0); }
  100% { box-shadow: 0 0 0 0 rgba(47, 111, 208, 0); }
}

/*
 * The "Artikel" label STAYS on a narrow screen.
 *
 * It used to be dropped below 30rem as the first bit of decoration worth
 * sacrificing, and that was right while it was decoration. It is a link to the
 * article index now, and hiding a destination is not a layout economy — it is
 * one fewer place a phone reader can go. It costs about seven characters.
 */

/* ---------------------------------------------------------------- popovers */

.popover {
  position: absolute;
  top: calc(100% + var(--sp-2));
  right: 0;
  z-index: var(--z-pop);
  width: min(21rem, calc(100vw - var(--sp-8)));
  padding: var(--sp-2);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--bg-raised);
  box-shadow: var(--inset-top), var(--shadow-pop);
  animation: pop-in var(--dur) var(--ease-out);
}

@keyframes pop-in {
  from { opacity: 0; transform: translateY(-6px) scale(0.98); }
  to { opacity: 1; transform: none; }
}

.popover__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
}

.popover__action {
  color: var(--blue-bright);
  font-size: var(--fs-xs);
  font-weight: var(--fw-semi);
}

/* --------------------------------------------------------- latest articles */

/*
 * The panel behind the bell: one month of articles, newest first. It is a list
 * of links, not a list of events — each row leads somewhere, so the whole row is
 * the target and the title carries the weight inside it.
 */
.latest__list {
  display: grid;
  gap: var(--sp-1);
  max-height: 60vh;
  overflow-y: auto;
}

.latest__item {
  display: grid;
  grid-template-columns: 10px 1fr;
  gap: var(--sp-3);
  padding: var(--sp-3);
  border-radius: var(--r-md);
  color: var(--text);
  transition: background var(--dur-fast) var(--ease);
}

.latest__item:hover { background: var(--surface); text-decoration: none; }
.latest__item:hover .latest__title { color: var(--text-bright); }

.latest__dot {
  width: 8px;
  height: 8px;
  margin-top: 7px;
  border-radius: 50%;
  background: transparent;
}

.latest__item.is-new .latest__dot { background: var(--blue-bright); }

.latest__text { display: grid; gap: 3px; min-width: 0; }

.latest__title {
  font-size: var(--fs-sm);
  font-weight: var(--fw-semi);
  line-height: var(--lh-snug);
}

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

.latest__note {
  padding: var(--sp-2) var(--sp-3) var(--sp-1);
  border-top: 1px solid var(--line-soft);
  margin-top: var(--sp-2);
}

/* -------------------------------------------------------------- list page */

.list__head {
  display: grid;
  gap: var(--sp-3);
  padding: var(--sp-10) 0 var(--sp-6);
}

/**
 * The toolbar.
 *
 * NOT STICKY, at any width. Search is something a reader does once, at the top,
 * and then stops thinking about; the list underneath is what they came to
 * scroll. Pinned under the floating pill it cost a permanent band of the
 * viewport and left every card title scrolling halfway behind it. It now
 * scrolls away with the rest of the page, and the topbar is the only chrome
 * that stays.
 */
.toolbar {
  display: grid;
  gap: var(--sp-3);
  /* Bleed to the viewport edges so the field has an opaque backdrop all the way
     out, instead of sliding over card artwork in the gutter. */
  margin-inline: calc(var(--gutter) * -1);
  padding: var(--sp-4) var(--gutter) var(--sp-3);
  background: var(--bg);
}

.search { position: relative; }

.search__icon {
  position: absolute;
  top: 50%;
  left: var(--sp-3);
  translate: 0 -50%;
  color: var(--text-3);
  pointer-events: none;
}

.search__input { padding-left: calc(var(--sp-3) * 2 + 17px); }

.filters {
  display: flex;
  gap: var(--sp-2);
  overflow-x: auto;
  /* Bleed the scroller to the gutter so chips do not appear clipped mid-page. */
  margin-inline: calc(var(--gutter) * -1);
  padding-inline: var(--gutter);
  scrollbar-width: none;
  /* Snap so a swipe lands on a whole chip rather than halfway through one. */
  scroll-snap-type: x proximity;
  scroll-padding-inline: var(--gutter);

  /*
   * Fade the trailing edge.
   *
   * A chip sliced by the viewport edge reads as a layout bug; the same chip
   * fading out reads as "there is more, keep swiping". The mask rides the
   * scroller, so it stays at the edge as the row moves.
   */
  -webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - var(--sp-8)), transparent 100%);
  mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - var(--sp-8)), transparent 100%);
}

.filters > .chip { scroll-snap-align: start; }

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

.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  flex: none;
  min-height: 36px;
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--surface);
  color: var(--text-2);
  font-size: var(--fs-xs);
  font-weight: var(--fw-semi);
  letter-spacing: 0.01em;
  transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease),
    background var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
}

/* The category chip row was removed from the toolbar, so the only state left is
   hover. `.is-active` and `.chip--danger` went with it: styling a class nothing
   applies is a promise the markup does not keep. */
.chip:hover { color: var(--text-bright); border-color: #333845; transform: translateY(-1px); }

.list__results { padding-block: var(--sp-4) var(--sp-16); }
.list__featured { display: grid; gap: var(--sp-3); margin-bottom: var(--sp-8); }
.list__count { margin-bottom: var(--sp-3); }
.list__more { margin-top: var(--sp-6); }

/* One column on a phone; more only when a card can stay legible. */
.card-grid {
  display: grid;
  gap: var(--stack);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
}

/* ------------------------------------------------------------ article card */

.a-card {
  border: 1px solid var(--line-soft);
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--inset-top);
  overflow: hidden;
  transition: border-color var(--dur) var(--ease), transform var(--dur) var(--ease),
    box-shadow var(--dur) var(--ease), background var(--dur) var(--ease);
}

.a-card:hover {
  border-color: var(--blue-line);
  transform: translateY(-3px);
  background: var(--surface-2);
  box-shadow: var(--inset-top), var(--shadow-md);
}

.a-card__link {
  display: grid;
  height: 100%;
  color: var(--text);
}

.a-card__link:hover { text-decoration: none; }

/**
 * The thumbnail is a positioned box with an absolutely-filled image, so its
 * height comes from the ratio (or from the grid row in the feature layout) and
 * never from the source image's intrinsic size. Without this, a square asset
 * stretches the hero card to its own height.
 */
/*
 * No fill behind the thumbnail: it carries a bottom fade, and an opaque
 * `--surface-2` is lighter than both the card and the artwork — masking pixels
 * away would reveal a pale grey and the image would read as brightening instead
 * of dissolving. The ratio still reserves the box, so nothing reflows.
 */
.a-card__thumb {
  position: relative;
  display: block;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

/*
 * No bottom fade.
 *
 * The thumbnail used to dissolve into the card with a gradient mask. It read as
 * unfinished rather than soft — the picture appeared to be missing its bottom
 * edge — so the image is simply shown whole, with the card's own rounded corner
 * as the finish.
 */
.a-card__thumb img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.55s var(--ease-out);
}

.a-card:hover .a-card__thumb img { transform: scale(1.035); }

.a-card__body {
  display: grid;
  align-content: start;
  gap: var(--sp-2);
  padding: var(--sp-5);
}

.a-card__date { display: block; color: var(--text-3); letter-spacing: 0.02em; }
.a-card__title { display: block; color: var(--text-bright); transition: color var(--dur-fast) var(--ease); }

.a-card__summary {
  color: var(--text-2);
  font-size: var(--fs-sm);
  line-height: var(--lh-normal);
  /* Three lines keeps every card in a row the same height. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.a-card__foot {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin-top: var(--sp-3);
  padding-top: var(--sp-3);
  border-top: 1px solid var(--line-soft);
}

.a-card__go {
  margin-left: auto;
  color: var(--blue-bright);
  transition: transform var(--dur-fast) var(--ease);
}

.a-card:hover .a-card__go { transform: translate(2px, -2px); }

.a-card--skeleton { pointer-events: none; }

/* The hero card goes side-by-side once there is room for both halves. */
@media (min-width: 48rem) {
  .a-card--feature .a-card__link {
    grid-template-columns: 1.15fr 1fr;
    align-items: stretch;
  }

  /* The text column sets the row height; the image fills whatever that is. */
  .a-card--feature .a-card__thumb { aspect-ratio: auto; height: 100%; min-height: 18rem; }
  .a-card--feature .a-card__body { padding: var(--sp-8); gap: var(--sp-3); }
  .a-card--feature .a-card__summary { -webkit-line-clamp: 4; font-size: var(--fs-base); }
}

/* ----------------------------------------------------------- article page */

.progress {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: var(--z-sticky);
  height: 3px;
}

.progress span {
  display: block;
  height: 100%;
  width: 0;
  background: linear-gradient(90deg, var(--blue), var(--blue-bright));
}

.article__hero {
  display: grid;
  gap: var(--sp-5);
  padding: var(--sp-12) 0 var(--sp-8);
}

/*
 * The byline sits directly on the page — no card, no band.
 *
 * Author and date are metadata, not content, so they get typography and
 * spacing instead of a container: the avatar tile is enough structure, and the
 * hero's own rhythm keeps the row where it belongs.
 */
.byline {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  flex-wrap: wrap;
}

/* The author's tile: brand-blue gradient, so the byline reads as a byline
   before a single word is read. */
.byline__avatar {
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  border: 1px solid var(--blue-line);
  border-radius: 50%;
  background: linear-gradient(180deg, var(--blue-hover), var(--blue));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), var(--shadow-sm);
  color: #fff;
  font-size: var(--fs-md);
  font-weight: var(--fw-bold);
}

.byline__who strong { color: var(--text-bright); font-weight: var(--fw-semi); letter-spacing: -0.01em; }

/*
 * `min-width: 0` on both columns.
 *
 * Flex items default to `min-width: auto`, so a long byline — a real author name
 * plus a role plus a date — could not shrink and widened the row past the
 * viewport, taking the hero and the whole page with it. The mock corpus's short
 * names hid this until the first real article was migrated.
 */
.byline__who { display: grid; min-width: 0; }

/*
 * Date · reading time, under the name.
 *
 * One inline line rather than a right-aligned column: it belongs to the byline
 * above it, and floating it to the far edge of a wide desktop measure put a
 * screen of empty space between a fact and the person it is about.
 */
.byline__when {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
  flex-wrap: wrap;
}

.byline__dot { color: var(--text-3); }

.article__cover { display: grid; gap: var(--sp-3); }

/*
 * The cover is the article's MAIN image, and it stays clear — no bottom fade.
 * The fade is a finish for preview images sitting inside a card or container
 * (the list thumbnails); the one full-size image the article is actually about
 * is shown whole, so nothing in it is dimmed. Once the article is open, the
 * reader sees the picture, not a fade-out of it.
 */
.article__cover img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-md);
}

/* The main caption is read, not scanned — one step up from the in-block size. */
.article__cover figcaption {
  font-size: var(--fs-sm);
  color: var(--text-2);
  line-height: var(--lh-normal);
}

.article__body {
  display: grid;
  gap: var(--stack);
  padding-bottom: var(--sp-8);
  /* See below — the body is itself a grid item and has to be allowed to shrink
     before its own children can be. */
  min-width: 0;
}

/*
 * Grid and flex items default to `min-width: auto`: "never shrink below your
 * content". A wide table therefore widened its block, which widened the article
 * body, which widened the container — and the whole PAGE scrolled sideways while
 * the table's own `overflow-x` never engaged. Every level between the container
 * and the scroll box has to opt out for the innermost one to do its job.
 */
.article > .container,
.article > .container > *,
.article__hero > *,
.article__body > * { min-width: 0; }

/*
 * A broken image must not be able to resize the page.
 *
 * When an <img> fails to load the browser lays out its ALT TEXT instead, and a
 * one-sentence alt has a min-content width of several hundred pixels. As a grid
 * item that cannot shrink, it widened its track past the viewport and took the
 * headline, the byline, and the whole document's scroll width with it — the page
 * appeared broken in a way that had nothing to do with the missing file. Capping
 * the box means a failed image costs its own space and nothing else.
 */
.article__cover img,
.frame__img,
.a-card__thumb img { max-width: 100%; }

/*
 * The maintenance promise: one quiet meta line under the byline.
 *
 * It is reassurance, not an announcement — so it is set at caption size in
 * tertiary ink, with only the refresh mark for recognition. No box: a box
 * around one sentence between the byline and the cover would compete with both.
 */
.article__update {
  display: flex;
  gap: var(--sp-2);
  align-items: flex-start;
  margin: 0;
}

.article__update-mark {
  flex-shrink: 0;
  display: inline-flex;
  margin-top: 2px;
  color: var(--text-3);
}

.article__update-text {
  margin: 0;
  color: var(--text-3);
  font-size: var(--fs-xs);
  line-height: var(--lh-normal);
}

.article__update-when time { color: var(--text-2); }

/* --------------------------------------------------------- preset gallery */

.gallery__list { padding-block: var(--sp-6) var(--sp-16); gap: var(--sp-10); }

.specimen { display: grid; gap: var(--sp-4); }

.specimen__head {
  display: grid;
  justify-items: start;
  gap: var(--sp-2);
  padding-bottom: var(--sp-3);
  border-bottom: 1px solid var(--line-soft);
}

.specimen__states {
  display: grid;
  gap: var(--sp-3);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}

.notfound { padding-block: var(--sp-16); }

/* ------------------------------------------------------------- lightbox */

/*
 * The zoomed image.
 *
 * A fixed overlay above everything, built only when a picture is clicked (see
 * ui/lightbox.ts) — so it is never in the prerendered HTML and never something a
 * crawler has to skip. It fades and scales in; the image itself is capped to the
 * viewport so a tall screenshot scrolls the overlay rather than the page behind.
 */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: grid;
  place-items: center;
  gap: var(--sp-3);
  padding: var(--sp-4);
  background: rgba(6, 7, 10, 0.82);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  overflow: auto;
  opacity: 0;
  transition: opacity var(--dur) var(--ease);
}

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

/*
 * An explicit width, not `max-width`.
 *
 * The overlay centres its children, which makes a grid item shrink to fit its
 * content — so the figure took the picture's intrinsic width and the image's
 * `width: 100%` resolved back to that same size, zooming nothing. Claiming the
 * available width up front is what gives the image room to grow into.
 */
.lightbox__figure {
  display: grid;
  gap: var(--sp-3);
  justify-items: center;
  margin: auto;
  width: min(100%, 80rem);
}

.lightbox__img {
  display: block;
  /*
   * Fill the space, do not merely un-crop.
   *
   * `width: auto` left a small picture at its own intrinsic size — and since the
   * inline figure stretches images to the column with `object-fit: cover`, the
   * "zoom" could come out SMALLER than the thumbnail it was opened from. Taking
   * the figure's width and letting height follow makes the picture actually
   * bigger; `max-height` then caps a tall one, and `contain` keeps it undistorted
   * when that cap bites.
   */
  width: 100%;
  height: auto;
  /* Room for the caption under it, so a tall image never pushes its own
     explanation off the bottom of the screen. */
  max-height: 82dvh;
  object-fit: contain;
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-pop);
  /* Scales up with the fade, so the picture arrives rather than blinks on. */
  transform: scale(0.98);
  transition: transform var(--dur) var(--ease);
}

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

.lightbox__caption {
  max-width: var(--w-reading);
  text-align: center;
  color: var(--text-2);
}

/* Top-right, clear of the picture: the overlay's one control. */
.lightbox__close {
  position: fixed;
  top: var(--sp-4);
  right: var(--sp-4);
  display: grid;
  place-items: center;
  width: var(--tap);
  height: var(--tap);
  border: 1px solid var(--line-soft);
  border-radius: 50%;
  background: var(--glass-bg);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  color: var(--text);
  transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}

.lightbox__close:hover { color: var(--text-bright); border-color: var(--blue-line); }

/* The page behind must not scroll while the overlay owns the screen. */
html.has-lightbox, html.has-lightbox body { overflow: hidden; }

/* A picture a reader can open says so on hover. */
.article__cover img,
.frame__img { cursor: zoom-in; }

/* Inside a link the click means "open the article", so no zoom affordance. */
a .frame__img { cursor: inherit; }

/*
 * A small magnifier badge, so a picture LOOKS openable before hover.
 *
 * `cursor: zoom-in` only appears once the pointer is over the image, and it is
 * invisible on a touch screen — where most reading happens — so nothing told a
 * phone reader the picture could be opened at all. This badge does, always.
 *
 * It is a decorative affordance, not content: `::after` draws it, no text node
 * carries it, and it is a magnifier icon rather than a word — so it stays out of
 * everything a crawler reads (SECURITY.md §7 forbids TEXT in pseudo-elements,
 * not an icon that labels a control).
 *
 * IT MATCHES `ZOOMABLE` IN ui/lightbox.ts EXACTLY: `.article__cover img,
 * .frame__img`. The first version excluded `.frame--auto` by copying the hover
 * rule below, which zooms the picture slightly and skips documents on purpose —
 * but the LIGHTBOX opens auto frames too, and they are 40 of the 83 frames in
 * the corpus. The badge would have been missing from half the images a reader
 * can actually open. An affordance that disagrees with the behaviour it
 * advertises is worse than none.
 */
.frame,
.article__cover { position: relative; }

.frame::after,
.article__cover::after {
  content: "";
  position: absolute;
  top: var(--sp-3);
  right: var(--sp-3);
  width: 30px;
  height: 30px;
  border-radius: var(--r-md);
  background-color: rgba(4, 8, 14, 0.62);
  background-repeat: no-repeat;
  background-position: center;
  background-size: 16px 16px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5'/%3E%3Cline x1='21' y1='21' x2='15.5' y2='15.5'/%3E%3C/svg%3E");
  border: 1px solid rgba(255, 255, 255, 0.14);
  opacity: 0.85;
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease);
}

/* A frame inside a link opens the article, not a lightbox — no zoom badge. */
a .frame::after { content: none; }

.frame:hover::after,
.article__cover:hover::after { opacity: 1; background-color: rgba(4, 8, 14, 0.8); }
