﻿/* ==========================================================================
   components.css — every class the markup depends on.
   Depends on tokens.css and base.css. There is exactly one rule per concept:
   a button is `.btn`, a card is `.art-card`, a surface is `.glass-panel`.
   Class names are unchanged from the previous stylesheet so no markup breaks.
   ========================================================================== */

/*
  ── Why the button system sits OUTSIDE @layer ─────────────────────────────

  Cascade layers are ordered against each other, but UNLAYERED styles beat
  EVERY layered style — regardless of specificity. Tailwind's CDN injects its
  preflight unlayered:

      button { appearance: button; background-color: transparent;
               background-image: none }

  That silently erased the primary button's gradient when the button rules
  lived in `@layer components`. Verified in the browser: `background-image:
  none` on a freshly created `.btn-primary`.

  An earlier `!important` on the padding had masked part of the symptom. The
  correct fix is to keep these declarations unlayered too, so normal
  specificity decides. Component-level rules that Tailwind does not target
  (.art-card, .glass-panel, dialog.lightbox) stay inside the layer.
*/

/* ══════════════════════════════════════════════════════════════════════════
   BUTTONS — one system
   `.btn-primary` and `.glow-button` were near-identical copies; they are now
   `.btn` plus a variant. Both legacy names remain as aliases so existing
   markup keeps working, and each resolves to the same declarations.

   The fill is flat ink. The gradient this rule used to carry is gone; the
   legacy `--_btn-bg` custom properties are kept so nothing that references
   them breaks, but artist.css overrides them to solid colours and this block
   now agrees.
   ══════════════════════════════════════════════════════════════════════════ */

.btn,
.btn-primary,
.glow-button {
  /* Local aliases so variants can retarget one property. */
  --_btn-bg: var(--text);
  --_btn-bg-hover: var(--accent);
  --_btn-edge: var(--text);

  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);

  /* WCAG 2.5.8: every interactive control is at least 44px tall. */
  min-height: var(--tap-min);
  padding: 0.65rem 1.25rem;

  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--bg-core);
  white-space: nowrap;

  background: var(--text);
  border: 1px solid var(--text);
  border-radius: var(--radius-sm);
  box-shadow: none;
  cursor: pointer;

  /* Colour only. The translateY lift and the halo this button used to carry
     were both tech cues: a button is a printed block, not a hovering object. */
  transition:
    background-color var(--t) var(--ease-out),
    border-color var(--t) var(--ease-out),
    color var(--t) var(--ease-out);
}

/* Hover effects are gated: on a touch device a sticky :hover leaves the
   button visibly stuck in its lifted state after the tap. */
@media (hover: hover) and (pointer: fine) {
  .btn:hover,
  .btn-primary:hover,
  .glow-button:hover {
    background: var(--accent);
    border-color: var(--accent);
    color: #fff;
  }
}

.btn:focus-visible,
.btn-primary:focus-visible,
.glow-button:focus-visible {
  outline: 2px solid var(--accent-ring);
  outline-offset: 3px;
}

.btn:active,
.btn-primary:active,
.glow-button:active {
  /* A single pixel of travel — paper pressed, not a spring released. */
  transform: translateY(1px);
  transition-duration: var(--t-instant);
}

.btn:disabled,
.btn[disabled],
.btn-primary:disabled,
.glow-button:disabled {
  opacity: 0.5;
  box-shadow: none;
  transform: none;
  pointer-events: none;
}

/* ── Variants ────────────────────────────────────────────────────────── */

/* Quiet secondary. Was `.btn-ghost`. Now a plain underlined word, the way a
   link is set in a book — no fill, no border, no lift. */
.btn-ghost {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--tap-min);
  padding: 0.65rem 0.25rem;
  font-size: var(--step--1);
  font-weight: 500;
  color: var(--text);
  background-color: transparent;
  border: 0;
  border-radius: 0;
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-underline-offset: 0.3em;
  text-decoration-thickness: 1px;
  cursor: pointer;
  transition: color var(--t) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .btn-ghost:hover {
    color: var(--accent);
  }
}

/* Borderless icon/text button. Was `.btn-quiet`. */
.btn-quiet {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  padding: 0.5rem;
  color: var(--text-muted);
  background-color: transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition:
    color var(--t) var(--ease-out),
    background-color var(--t) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .btn-quiet:hover {
    color: var(--text);
    background-color: color-mix(in oklab, var(--text) 6%, transparent);
  }
}

/* Destructive action. */
.btn-danger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--tap-min);
  padding: 0.65rem 1.125rem;
  font-size: var(--step--1);
  color: var(--danger);
  background-color: var(--danger-soft);
  border: 1px solid color-mix(in oklab, var(--danger) 45%, transparent);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background-color var(--t) var(--ease-out), color var(--t) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .btn-danger:hover { background-color: color-mix(in oklab, var(--danger) 18%, transparent); }
}

.btn-danger:active { transform: translateY(1px); }

/*
  Form controls, chips and the nav also collide with Tailwind preflight
  (`input`, `button`, `a` resets), so they sit outside the layer for the same
  reason as the buttons above.
*/

.field {
  width: 100%;
  min-height: var(--tap-min);
  padding: 0.6rem 0.25rem;
  font-size: var(--step--1);
  color: var(--text);
  /* A ruled line on paper, not a filled box. */
  background-color: transparent;
  border: 0;
  border-bottom: 1px solid var(--text);
  border-radius: 0;
  outline: none;
  transition: border-color var(--t) var(--ease-out);
}

.field::placeholder { color: var(--text-muted); font-style: italic; }

.field:focus {
  border-bottom-color: var(--accent);
  border-bottom-width: 2px;
  /* Compensate for the extra 1px so the text does not jump. */
  padding-bottom: calc(0.6rem - 1px);
}

.field::-webkit-search-cancel-button { cursor: pointer; }

textarea.field { min-height: 6rem; resize: vertical; line-height: var(--leading-body); }
select.field { cursor: pointer; }

.label {
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--label-size);
  font-weight: 500;
  letter-spacing: var(--label-tracking);
  text-transform: uppercase;
  color: var(--text-muted);
}

/* Compact label used for filters, tabs and quick links. Nearly square, like a
   cut strip of paper — a pill would be a tech cue. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 2rem;
  padding: 0.35rem 0.75rem;
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--text-soft);
  background-color: transparent;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  cursor: pointer;
  /* Background/border only — a chip must not move when toggled. */
  transition:
    background-color var(--t-fast) var(--ease-out),
    border-color var(--t-fast) var(--ease-out),
    color var(--t-fast) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .chip:hover {
    color: var(--text);
    border-color: var(--text-muted);
    background-color: color-mix(in oklab, var(--text) 5%, transparent);
  }
}

/* Active: ink underline and an accent hairline. A gradient fill here was one
   of the loudest tech cues on the page. */
.chip[data-active='true'] {
  color: var(--text);
  background-color: transparent;
  border-color: var(--accent);
  border-bottom-width: 2px;
}

/* ── Privacy toggle ────────────────────────────────────────────────────────
   Defined ONCE, outside `@layer components`, together with the controls above.
   A duplicate copy also lived inside the layer; being unlayered, this one
   always won, so the layered copy was dead weight and the two could only
   diverge silently. One rule, one source of truth. */

.nav-link {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  inline-size: 100%;
  min-height: var(--tap-min);
  padding: 0.625rem 0.875rem;
  font-size: var(--step--1);
  font-weight: 500;
  text-align: start;
  color: var(--text-muted);
  background-color: transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition:
    color var(--t) var(--ease-out),
    background-color var(--t) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .nav-link:hover {
    color: var(--text);
    background-color: color-mix(in oklab, var(--text) 5%, transparent);
  }
}

/* Active: an ink wash and a short accent bar on the leading edge. The gradient
   fill and the glowing marker this used to carry are both gone. */
.nav-link[data-active='true'] {
  color: var(--text);
  background-color: color-mix(in oklab, var(--text) 6%, transparent);
}

/* Active marker: a short accent bar on the leading edge. */
.nav-link[data-active='true']::before {
  content: '';
  position: absolute;
  inset-block: 50% auto;
  inset-inline-start: 0;
  translate: 0 -50%;
  inline-size: 2px;
  block-size: 1.25rem;
  border-radius: 0;
  background-image: none;
  background-color: var(--accent);
}

/* ── Snackbar (position: fixed, so preflight cannot interfere, but it shares
      the surface treatment and is kept beside its siblings) ────────────── */

.snackbar {
  position: fixed;
  inset-block-end: var(--space-5);
  inset-inline-start: var(--space-6);
  z-index: var(--z-toast);
  max-inline-size: 25rem;
  padding: var(--space-4) var(--space-5);
  font-size: var(--step--1);
  color: var(--text);
  opacity: 0;
  translate: 0 20px;
  pointer-events: none;

  /* Opaque paper. The frosted veil this used to carry is retired — a toast is
     a slip of paper laid on the desk, not a pane of glass. */
  background: var(--bg-surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: 0 10px 30px -12px rgb(60 40 20 / 0.45);

  transition:
    opacity var(--t) var(--ease-out),
    translate var(--t) var(--ease-out);
}

.snackbar.is-visible { opacity: 1; translate: 0 0; }

.snackbar--success { border-color: color-mix(in oklab, var(--success) 55%, transparent); }
.snackbar--error   { border-color: color-mix(in oklab, var(--danger) 60%, transparent); }
.snackbar--info    { border-color: var(--line); }

@media (max-width: 40rem) {
  .snackbar {
    inset: auto var(--space-4) var(--space-4) var(--space-4);
    max-inline-size: none;
  }
}

@layer components {

  /* ══════════════════════════════════════════════════════════════════════
     INTERACTIVE CONTROLS — unlayered, deliberately
     Button, field, chip, nav-link, toggle. Tailwind's preflight is injected
     unlayered and resets `button { background-image: none }`; unlayered styles
     beat every layered style, so these must sit outside @layer components.
     `.check-toggle` joins them: it was already unlayered, so the layered copy
     that duplicated it was dead weight.
     ══════════════════════════════════════════════════════════════════════ */

  /* ── Privacy / visibility toggle — a real checkbox, visually replaced ──── */
  .check-toggle {
    position: relative;
    display: inline-block;
    flex: none;
    inline-size: 56px;
    block-size: 32px;
    cursor: pointer;
    user-select: none;
  }

  /* The input is visually hidden but stays in the layer (not display:none), so
     it remains focusable and is announced correctly by screen readers. */
  .check-toggle input {
    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;
    cursor: pointer;
  }

  .check-toggle label {
    position: absolute;
    inset: 0;
    background: var(--bg-overlay);
    border: 1px solid var(--line);
    border-radius: var(--radius-pill);
    transition: background var(--t) var(--ease-out);
    pointer-events: none;
  }

  .check-toggle__ball {
    position: absolute;
    inset-block: 3px;
    inset-inline-start: 3px;
    aspect-ratio: 1;
    background: white;
    border-radius: 50%;
    /* `translate` is its own property: cheaper than transform, and it will not
       fight a transform set by an ancestor animation. */
    transition: translate var(--t) var(--ease-out);
  }

  .check-toggle input:checked + label { background: var(--accent); }
  .check-toggle input:checked + label .check-toggle__ball { translate: 24px 0; }

  .check-toggle input:focus-visible + label {
    outline: 2px solid var(--accent-ring);
    outline-offset: 3px;
  }

  /* ══════════════════════════════════════════════════════════════════════
     TYPOGRAPHY UTILITIES
     Anchor target for README-style headings. scroll-padding-top on <html>
     keeps the sticky header from covering the heading it just jumped to.
     ══════════════════════════════════════════════════════════════════════ */

  .readme-heading { scroll-margin-top: calc(var(--header-h) + var(--space-5)); }

  /* ══════════════════════════════════════════════════════════════════════
     SURFACES
     ══════════════════════════════════════════════════════════════════════ */

  /* Paper surface.
     The frosted-glass look is retired: the palette is warm paper now, and a
     blur behind a translucent panel reads as a machine-lit window. A panel is
     a sheet laid on a sheet — an opaque fill, a hairline, a short warm shadow. */
  .glass-panel,
  .glass {
    position: relative;
    background: var(--bg-surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-inset-top), var(--shadow);
  }

  /* Chrome that floats over content: the sidebar and the top bar. Opaque, so
     content scrolling under it can never collide with the text on it. */
  .glass-float {
    background: var(--bg-core);
    border: 1px solid var(--line);
  }

  /* Generic panel used by dashboards and settings. */
  .card {
    background: var(--bg-surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-inset-top), var(--shadow-rest);
    transition: border-color var(--t) var(--ease-out);
  }

  @media (hover: hover) and (pointer: fine) {
    .card:hover { border-color: var(--line-strong); }
  }

  /* ── Upload / drop zone ────────────────────────────────────────────────── */
  .input-drag {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    aspect-ratio: 1;
    padding: var(--space-4);
    text-align: center;
    background: transparent;
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-sm);
    cursor: pointer;
    overflow: hidden;
    transition:
      border-color var(--t) var(--ease-out),
      background var(--t) var(--ease-out);
  }

  @media (hover: hover) and (pointer: fine) {
    .input-drag:hover {
      border-color: var(--text-muted);
      background: color-mix(in oklab, var(--accent) 5%, transparent);
    }
  }

  .input-drag.is-drag,
  .input-drag:focus-visible {
    border-color: var(--accent);
    background: color-mix(in oklab, var(--accent) 12%, transparent);
  }

  /* Once a file is chosen, the dashed affordance should stop reading as empty.
     The attribute selector covers both the attribute and the class form. */
  .input-drag.has-image,
  .input-drag[data-filled='true'] {
    border-style: solid;
    border-color: var(--line);
  }

  .input-drag img {
    position: absolute;
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }

  .input-drag__hint { position: relative; z-index: 1; }

  /* ══════════════════════════════════════════════════════════════════════
     ARTWORK CARD
     The hero of the gallery. Chrome recedes; the image carries the contrast.
     ══════════════════════════════════════════════════════════════════════ */

  /* A print in a frame, with a passe-partout mat and a caption underneath.
     The lift-and-scale hover, the cursor spotlight and the gradient fill are
     all gone: the image warms very slightly and the frame shadow deepens, which
     is what a print does when you lean toward it, not what a card does when a
     pointer lands on it. The rules live in artist.css ("5. Artwork") because
     they must beat this block; this comment replaces the retired copies.

     Only the aspect-ratio reservation stays here: it must apply at rest, before
     any interaction, so the masonry grid cannot shift as thumbnails load. */

  .art-card img {
    inline-size: 100%;
    aspect-ratio: var(--ar, 4 / 5);
    object-fit: cover;
  }

  /* An image that fails to load should not leave an invisible hole. */
  .art-card img:not([src]),
  .art-card img[src=''] {
    background: var(--bg-sunken);
  }

  /* ══════════════════════════════════════════════════════════════════════
     GALLERY LAYOUT — masonry
     CSS columns, with a container query so the column count follows the
     gallery's own width rather than the viewport's. That matters because the
     gallery sits inside a sidebar-constrained column.
     ══════════════════════════════════════════════════════════════════════ */

  .masonry-grid {
    column-count: 1;
    column-gap: var(--gallery-gap);
  }

  .masonry-item {
    /* Columns break inside an element by default, which splits a card across
       two columns. */
    break-inside: avoid;
    margin-bottom: var(--gallery-gap);
    /* Prevents Safari from clipping the hover transform inside a column. */
    transform: translateZ(0);
  }

  @media (min-width: 40rem)  { .masonry-grid { column-count: 2; } }
  @media (min-width: 64rem)  { .masonry-grid { column-count: 3; } }
  @media (min-width: 80rem)  { .masonry-grid { column-count: 4; } }
  @media (min-width: 120rem) { .masonry-grid { column-count: 5; } }

  /* Container queries: where supported, the column count responds to the
     gallery element's inline size, not the page width. */
  @supports (container-type: inline-size) {
    .masonry-grid { container-type: inline-size; column-count: 1; }

    @container (min-width: 34rem)  { .masonry-grid { column-count: 2; } }
    @container (min-width: 58rem)  { .masonry-grid { column-count: 3; } }
    @container (min-width: 76rem)  { .masonry-grid { column-count: 4; } }
    @container (min-width: 110rem) { .masonry-grid { column-count: 5; } }
  }

  /* ══════════════════════════════════════════════════════════════════════
     LIGHTBOX — native <dialog>
     The element gives us: focus trapping, inert background, Escape handling
     and ::backdrop, all for free and correctly. The custom overlay it
     replaced reimplemented all four, approximately.
     ══════════════════════════════════════════════════════════════════════ */

  dialog.lightbox {
    inline-size: 100%;
    max-inline-size: 100vw;
    max-block-size: 100dvh;
    margin: auto;
    border: 0;
    padding: 0;
    background: transparent;
    overflow: hidden;
  }

  /* The panel-family dialogs (character/story/artwork forms, confirm). They are
     also native <dialog> elements and rely on the UA's own centring, so all we
     add is the padding and the grid that centres the panel inside the box. */
  dialog.modal-dialog {
    max-inline-size: none;
    max-block-size: none;
    inline-size: 100%;
    block-size: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    overflow: visible;
  }

  /* The click-to-dismiss surface. A native dialog does not paint a backdrop for
     panel dialogs (only the lightbox uses ::backdrop), so this div carries it. */
  dialog.modal-dialog > [data-backdrop] {
    position: fixed;
    inset: 0;
    background: rgb(24 18 14 / 0.82);
  }

  dialog.lightbox::backdrop {
    background-color: rgb(24 18 14 / 0.92);
  }

  /* @starting-style animates the dialog in on its first paint (when it goes
     from display:none to open). Without it, a <dialog> cannot be transitioned
     in — the opening frame is the final frame. */
  @supports selector(:open) {
    dialog.lightbox {
      opacity: 0;
      scale: 0.98;
      transition:
        opacity var(--t-fast) var(--ease-out),
        scale var(--t) var(--ease-out);
    }

    dialog.lightbox[open] { opacity: 1; scale: 1; }

    @starting-style {
      dialog.lightbox[open] { opacity: 0; scale: 0.98; }
    }

    /* Leaving: keep the closing frame for the duration of the transition. */
    dialog.lightbox.is-closing {
      opacity: 0;
      scale: 0.98;
      transition:
        opacity var(--t-fast) var(--ease-out),
        scale var(--t-fast) var(--ease-out);
    }
  }

  /* Fallback for browsers without :open / @starting-style. */
  @supports not selector(:open) {
    dialog.lightbox[open] { animation: lightboxRise var(--t) var(--ease-out); }
  }

  .lightbox-panel {
    display: grid;
    gap: 0;
    max-block-size: 100dvh;
    overflow: auto;
    background: var(--bg-surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: 0 30px 60px -20px rgb(60 40 20 / 0.55);
  }

  dialog.lightbox img {
    max-block-size: 88dvh;
    inline-size: auto;
    margin: auto;
    border-radius: 0;
    object-fit: contain;
  }

  /* The legacy custom overlay, kept so a stale cached bundle still renders
     correctly rather than showing an unstyled transparent layer. */
  .lightbox-backdrop {
    background-color: rgb(24 18 14 / 0.92);
  }

  /* ══════════════════════════════════════════════════════════════════════
     STORY TIMELINE
     `.story-chapter-line` is the boundary the story list uses; `.timeline` is
     the same spine when the list itself owns it. They share one visual rule.
     ══════════════════════════════════════════════════════════════════════ */

  .story-chapter-line {
    position: relative;
    padding-inline-start: 2.5rem;
  }

  /* The chapter spine is a pencil line; it never competes with the artwork.
     One rule serves both `.story-chapter-line` (the list carries the boundary)
     and `.timeline` (the list owns the spine directly). */
  .story-chapter-line::before,
  .timeline::before {
    content: '';
    position: absolute;
    inset-block: 0;
    inset-inline-start: 1rem;
    inline-size: 1px;
    background: var(--line);
  }

  .timeline { position: relative; padding-inline-start: 2.5rem; }

  /* ══════════════════════════════════════════════════════════════════════
     NAVIGATION
     Defined once, outside the layer, with the other controls — this copy used
     to shadow it. See the note at the top of this file.
     ══════════════════════════════════════════════════════════════════════ */

  /* ══════════════════════════════════════════════════════════════════════
     FEEDBACK — snackbar (defined once, outside the layer)
     ══════════════════════════════════════════════════════════════════════ */

  /* ── Loading state ──────────────────────────────────────────────────────
     A shimmer is a tech cue: it says "data is arriving from a machine", not
     "the paint is still drying". A skeleton sits quietly at a paper tone until
     its content replaces it. */
  .skeleton {
    background: var(--bg-sunken);
    border-radius: var(--radius-sm);
  }

  /* ── Section headings ────────────────────────────────────────────────────
     A hairline rule under the title. The soft bloom this used to paint behind
     it was a halo, which is exactly the cue this direction removes. */
  .title-card {
    position: relative;
    padding-block-end: var(--space-5);
    margin-block-end: var(--space-1);
  }

  .title-card::after {
    content: '';
    position: absolute;
    inset-inline: 0;
    inset-block-end: 0;
    block-size: 1px;
    background: var(--line);
  }

  .title-card > div { position: relative; z-index: 1; }

  /* The section heading itself — fluid, balanced, display face. */
  .section-title {
    font-family: var(--font-display);
    font-size: var(--step-3);
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-display);
    color: var(--text);
    text-wrap: balance;
  }

  /* ══════════════════════════════════════════════════════════════════════
     MOTION PRIMITIVES
     All transform/opacity/filter/clip-path only. Each is opt-in via a class or
     data attribute, so nothing animates that the author did not ask for.
     ══════════════════════════════════════════════════════════════════════ */

  /* Staggered entrance. Set --i on each item to offset it.
     A gentle 12px rise, not the 24px the tech pass used: the entrance should
     feel like a page settling, not content launching into place. */
  .reveal {
    opacity: 0;
    translate: 0 12px;
    animation: rise 0.6s var(--ease-out) forwards;
    animation-delay: calc(var(--i, 0) * 60ms);
  }

  @keyframes rise {
    to { opacity: 1; translate: 0 0; }
  }

  /* Scroll-driven reveal. Progressive enhancement: without support the
     element simply sits in its final state. */
  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      .scroll-reveal {
        opacity: 0;
        translate: 0 20px;
        animation: rise linear both;
        animation-timeline: view();
        animation-range: entry 0% cover 30%;
      }
    }
  }

  /* Fallback path for engines without `animation-timeline: view()` (notably
     Safari). gallery.js adds `.js-scroll-reveal` only when it has taken over,
     then adds `.is-in` when the element enters the viewport. The two paths are
     mutually exclusive, so the native keyframes never double-run here. */
  .scroll-reveal.js-scroll-reveal {
    opacity: 0;
    translate: 0 20px;
    animation: none;
    transition:
      opacity var(--t-slow) var(--ease-out),
      translate var(--t-slow) var(--ease-out);
  }

  .scroll-reveal.js-scroll-reveal.is-in {
    opacity: 1;
    translate: 0 0;
  }

  /* Text mask reveal: the line slides up from behind a clip. */
  .mask {
    display: block;
    overflow: clip;
  }

  .mask > span {
    display: block;
    transform: translateY(110%);
    transition: transform 0.9s var(--ease-in-out);
  }

  .is-in .mask > span { transform: translateY(0); }

  /* Clip-path image reveal. */
  .clip-reveal {
    clip-path: inset(0 0 100% 0);
    transition: clip-path var(--t-veil) var(--ease-in-out);
  }

  .is-in .clip-reveal { clip-path: inset(0); }

  /* Reduced motion: the transition still runs (at ~0ms via the token), but the
     initial clip is removed too, so an element is never parked in an invisible
     state waiting for a transition that will not visibly happen. */
  @media (prefers-reduced-motion: reduce) {
    .clip-reveal { clip-path: none; }
    .mask > span { transform: none; }
  }

  /* Underline sweep for links and list rows. */
  .sweep {
    position: relative;
    background:
      linear-gradient(var(--accent), var(--accent)) 0 100% / 0 2px no-repeat;
    transition: background-size 0.5s var(--ease-out);
  }

  .sweep:hover,
  .sweep:focus-visible { background-size: 100% 2px; }

  /* Marquee, only when the title actually overflows (gallery.js toggles the
     class, so short titles never animate). */
  .marquee {
    overflow: hidden;
    white-space: nowrap;
  }

  .marquee.is-overflowing > span {
    display: inline-block;
    padding-inline-end: 3rem;
    animation: marquee 12s linear infinite;
  }

  .marquee.is-overflowing:hover > span { animation-play-state: paused; }

  @keyframes marquee { to { transform: translateX(-100%); } }

  /* Text over artwork: difference blend keeps it legible on any image. */
  .over-art {
    color: white;
    mix-blend-mode: difference;
    pointer-events: none;
  }

  /* Cursor-tracked spotlight shared by cards and panels. */
  .spotlight::before {
    transition: opacity var(--t) var(--ease-out);
  }

  /* Reading progress bar. Scroll-driven where supported, static otherwise. */
  .progress {
    position: fixed;
    inset: 0 0 auto 0;
    block-size: 3px;
    z-index: var(--z-sticky);
    background: var(--accent);
    transform-origin: left;
    scale: 0 1;
  }

  @supports (animation-timeline: scroll()) {
    @media (prefers-reduced-motion: no-preference) {
      .progress {
        animation: grow linear both;
        animation-timeline: scroll(root);
      }
    }
  }

  @keyframes grow { to { scale: 1 1; } }

  /* Stagger utility: apply to a parent, children offset automatically. */
  .stagger > * {
    opacity: 0;
    translate: 0 16px;
    animation: rise 0.6s var(--ease-out) forwards;
  }

  .stagger > *:nth-child(1) { animation-delay: 0ms; }
  .stagger > *:nth-child(2) { animation-delay: 60ms; }
  .stagger > *:nth-child(3) { animation-delay: 120ms; }
  .stagger > *:nth-child(4) { animation-delay: 180ms; }
  .stagger > *:nth-child(5) { animation-delay: 240ms; }
  .stagger > *:nth-child(6) { animation-delay: 300ms; }
  .stagger > *:nth-child(n + 7) { animation-delay: 360ms; }

  /* Count-up highlight, driven by ui/toast.js animateCount(). */
  .animate-count-pulse { animation: aniCountPulse 0.7s var(--ease-out) forwards; }

  @keyframes aniCountPulse {
    0%   { transform: scale(1);   opacity: 1; }
    60%  { transform: scale(1.3); opacity: 0.4; }
    100% { transform: scale(1.4); opacity: 0; }
  }

  /* Self-dismissing dropdown reveal. */
  .animate-dropdown { animation: aniDropdownHideShow 5s var(--ease-in-out) forwards; }

  @keyframes aniDropdownHideShow {
    0%   { transform: translateY(-20px); max-height: 0;     opacity: 0; }
    10%  { transform: translateY(0);     max-height: 500px; opacity: 1; visibility: visible; }
    90%  { transform: translateY(0);     max-height: 500px; opacity: 1; visibility: visible; }
    100% { transform: translateY(-20px); max-height: 0;     opacity: 0; visibility: hidden; }
  }

  /* Legibility scrim used under text laid over an image. */
  .scrim::before {
    content: '';
    position: absolute;
    inset-inline: 0;
    inset-block-end: 0;
    block-size: 60%;
    pointer-events: none;
    background: linear-gradient(
      to top,
      color-mix(in oklab, var(--bg-sunken) 92%, transparent),
      transparent
    );
    z-index: 1;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   REDUCED MOTION
   Durations collapse via tokens; this block handles the cases a token cannot
   reach — entrance animations that start at opacity:0 must be forced to their
   final state, or the content simply never appears.
   ══════════════════════════════════════════════════════════════════════════ */

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

  .reveal,
  .scroll-reveal,
  .stagger > * {
    opacity: 1 !important;
    translate: none !important;
    animation: none !important;
  }

  .mask > span { transform: none !important; }
  .clip-reveal { clip-path: none !important; }
  .marquee.is-overflowing > span { animation: none !important; }

  .art-card:hover { transform: none; }
}

/* ══════════════════════════════════════════════════════════════════════════
   VIEW TRANSITIONS — gallery → lightbox
   ══════════════════════════════════════════════════════════════════════════ */

@view-transition { navigation: auto; }

::view-transition-group(*) {
  animation-duration: 0.5s;
  animation-timing-function: var(--ease-out);
}

/* The thumbnail morphs into the lightbox image; everything else cross-fades. */
::view-transition-old(gallery-image),
::view-transition-new(gallery-image) {
  animation-duration: 0.45s;
  animation-timing-function: var(--ease-out);
}

/* ══════════════════════════════════════════════════════════════════════════
   UTILITIES
   The highest-priority layer: a utility always wins over a component, which is
   what removes the need for selector-padding or !important. Declared last so
   the intent is readable in source order as well as in the layer statement.
   ══════════════════════════════════════════════════════════════════════════ */

@layer utilities {

  /* Visually hidden but announced. Used for the skip link, and for exposing a
     keyboard/screen-reader equivalent of visually-presented content. */
  .sr-only {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  /* The same element becomes a normal, visible block on focus. */
  .sr-only-focusable:focus-visible {
    position: static;
    inline-size: auto;
    block-size: auto;
    padding: var(--space-2) var(--space-4);
    margin: 0;
    overflow: visible;
    clip-path: none;
    white-space: normal;
  }

  /* Numbers that must not jitter as their digits change. */
  .tabular {
    font-variant-numeric: tabular-nums;
    font-feature-settings: 'tnum' 1;
  }

  /* Reading measure: 60–75ch keeps a line comfortable at any viewport width. */
  .measure { max-inline-size: var(--measure); }
  .prose   { max-inline-size: var(--measure-prose); }

  /* Lifted out of the document flow entirely, for text that is decoration. */
  .visually-hidden-until-focus { position: absolute; }
}
