/* ==========================================================================
   components.css — panels, header bars, Top 6, photography, album viewer.
   No colours are defined here. Tokens only.
   ========================================================================== */

/* --- Typographic base ---------------------------------------------------- */

h1, h2, h3, h4 { margin: 0; font-weight: 400; }
p  { margin: 0 0 var(--pad); }
p:last-child { margin-bottom: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
img { border: 0; }

a {
  color: var(--link);
  text-decoration: underline;
  text-underline-offset: 2px;
}
a:visited { color: var(--link); }        /* period-correct purple fights the palette */

/* Hover must not depend on colour alone, so the rule thickens too. */
a:hover,
a:focus-visible {
  color: var(--link-hover);
  text-decoration-thickness: 2px;
}

/* Focus ring, two-tone on purpose. A bright --focus outline backed by a
   dark --focus-back ring: on a light panel the backing carries it, on a
   near-black plate the outline does. That is what lets one ring stay
   visible on every surface of every skin — measured by
   tools/check-contrast.mjs, which tests the better half of the pair. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--focus-back);
}

.data {
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  color: var(--ink-soft);
}

/* --- Panel --------------------------------------------------------------- */

/* MySpace outlined every panel in the same colour as its header bar, so the
   bar and the frame read as one piece of furniture. That single move does
   more period work than any amount of ornament. */
.panel {
  background: var(--paper);
  border: 1px solid var(--bar);
  box-shadow:
    inset  1px  1px 0 var(--bevel),
    inset -1px -1px 0 var(--bevel-lo);
}

.panel__body { padding: var(--pad); }

/* The section header bar. This carries most of the period feel:
   a solid bar with condensed uppercase signage type, and the one
   gradient allowed anywhere on the site — a barely-there vertical wash. */
.panel__head {
  display: flex;
  align-items: center;
  gap: var(--pad-tight);
  min-height: var(--bar-h);
  padding: 2px var(--pad-tight) 1px;
  background: linear-gradient(180deg, var(--bar) 0%, var(--bar-lo) 100%);
  border-bottom: 1px solid var(--bar-lo);
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bar) 62%, var(--paper));
  color: var(--bar-ink);
  font-family: var(--font-display);
  font-size: var(--fs-head);
  font-weight: 600;
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: var(--track-display);
  text-shadow: 1px 1px 0 var(--bar-lo);
}

/* The small block that sits either side of the label in the layout sketch. */
.panel__head::before,
.panel__head::after {
  content: '';
  width: 5px; height: 5px;
  flex: 0 0 5px;
  background: var(--accent);
  box-shadow: 0 0 0 1px var(--bar-lo);
}

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

.panel__head .count {
  font-family: var(--font-ui);
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  text-transform: none;
  opacity: .9;
}

/* --- Name bar ------------------------------------------------------------ */

/* The name bar is a dark plate. That is not decoration: the chrome bevel
   is a light metallic gradient and it can only carry contrast over
   something dark. Every skin's --plate is near-black for this reason. */
.namebar {
  margin-bottom: var(--gap);
  background: var(--plate);
  border: 1px solid var(--accent);
  box-shadow: inset 0 0 0 1px var(--bar-lo);
  padding: var(--pad) var(--pad) var(--pad-tight);
}

/* THE NAME IS CENTRED IN THE BAR, at Kearnan's instruction of 8 Oct 2026,
   when it became INHERIT THE EARTH. Three columns, the outer two equal, so
   the name sits on the bar's own centre line whatever the status beside it
   is wide — `space-between` would centre it on the gap instead. */
.namebar__row {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: baseline;
  gap: var(--pad);
}

.namebar__name {
  grid-column: 2;
  text-align: center;
  font-family: var(--font-display);
  font-size: var(--fs-name);
  line-height: 1.05;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--plate-ink);
}

.namebar__status {
  grid-column: 3;
  justify-self: end;
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  color: var(--plate-soft);
}
.namebar__status::before { content: '[ '; }
.namebar__status::after  { content: ' ]'; }

.dot {
  display: inline-block;
  width: 6px; height: 6px;
  margin-left: 4px;
  vertical-align: baseline;
  background: var(--accent-3);
  box-shadow: 0 0 6px var(--accent-3);
}

.namebar__login {
  margin: var(--pad-tight) 0 0;
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  color: var(--plate-soft);
}
.namebar__login .sep { color: var(--accent); padding: 0 5px; }

/* --- Portrait ------------------------------------------------------------ */

.portrait { margin: 0; }

/* --- Photography ---------------------------------------------------------
   Over-processed, per skin. Heavy saturate + contrast, then a hue-rotate
   that comes from the active skin — which is what makes one photograph
   read four different ways rather than four tinted copies of the same
   thing. Then a colour wash in a blend mode and a hard vignette.

   All of it is applied in CSS to whatever is in the <img>. That is
   deliberate: the 72 real photographs will need no grading before upload,
   and the treatment behaves identically on the generated stand-ins now and
   on real photographs later.
   -------------------------------------------------------------------------- */

.shot {
  position: relative;
  display: block;
  overflow: hidden;
  background: var(--ink);
  line-height: 0;
}

/* <picture> is an inline wrapper with no box of its own; it has to be made
   to fill .shot or the <img> inside it sizes to its intrinsic dimensions and
   overflows. This bit the covers when <picture> was introduced. */
.shot picture {
  display: block;
  width: 100%;
  height: 100%;
}

.shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: var(--photo);
}

/* The viewer frame shows the whole photograph, so it contains rather than
   crops — a film border cropped off at full screen defeats the point. */
.viewer__frame picture { display: block; }

/* The skin's colour cast, pushed hard. `overlay` rather than `soft-light`:
   soft-light is the polite one, and this is not a polite palette. */
.shot::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  background: var(--wash);
  mix-blend-mode: overlay;
  opacity: var(--wash-op);
  pointer-events: none;
}

/* Vignette: harder than the old one — every frame closes down at the edge. */
.shot::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: radial-gradient(
    118% 118% at 50% 48%,
    transparent 28%,
    color-mix(in srgb, var(--plate) calc(var(--vignette) * .45), transparent) 66%,
    color-mix(in srgb, var(--plate) var(--vignette), transparent) 100%
  );
}

.shot--sq { aspect-ratio: 1 / 1; }
.shot--wide   { aspect-ratio: 3 / 2; }

/* --- Rail tone -----------------------------------------------------------
   THE RAIL PHOTOGRAPH ONLY, and nothing else on the page.

   Display_pic.JPG comes in warm — streaks of orange light across a brick
   basement — and against this palette it reads as a visitor. This pulls its
   hue back toward the page in CSS, so the file on disk is untouched and so
   is the panel frame around it: the treatment lives INSIDE .shot, under the
   existing wash and vignette, and never reaches an edge.

   Two treatments, switched by one attribute in index.html:

       data-tone="wash"      LIVE. Detail survives, the hue cools.
       data-tone="duotone"   greyscale, navy shadows, gold highlights.

   --rail-tone-strength is the one knob. Each tone sets its own default
   below; change the number and nothing else has to move.

   Scoped to .rail-tone[data-tone] and nothing broader, deliberately: the
   Top 6 covers, the inline album grids, the viewer and the character panel
   have no such container and cannot be caught by it, now or later.

   No new colour is named here, and no new hex: the wash is --pool, the
   duotone is --ink and --accent. --accent is the gold in this palette; there
   is no --halogen token. Read the note on the duotone's darken layer below
   before changing either of its two colours.
   -------------------------------------------------------------------------- */

.rail-tone {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  /* Contain the blending. Without this the layers below would blend with
     the panel and the sky behind the photograph as well as with it. */
  isolation: isolate;
}

.rail-tone[data-tone='wash']    { --rail-tone-strength: .40; }
.rail-tone[data-tone='duotone'] { --rail-tone-strength: .85; }

/* Everything that follows is gated on blend support. Without it the browser
   falls through to `.shot img`'s plain house treatment with no overlay at
   all — the photograph, in colour, legibly. Not a flat block. */

@supports (mix-blend-mode: color) {

  /* WASH. Saturation comes down from the house --ph-sat toward neutral as
     strength rises — at strength 0 this is exactly the house treatment —
     and contrast lifts a few per cent, no more. */
  .rail-tone[data-tone='wash'] img {
    filter:
      saturate(calc(var(--ph-sat) * (1 - var(--rail-tone-strength))))
      contrast(calc(var(--ph-con) * (1 + var(--rail-tone-strength) * .1)))
      hue-rotate(var(--ph-hue));
  }

  /* `color` takes hue and saturation from this layer and luminosity from the
     photograph, so the lighting in the shot survives however hard it is
     pushed. That is the whole reason it is not `overlay` like .shot::before. */
  .rail-tone[data-tone='wash']::before {
    content: '';
    position: absolute;
    inset: 0;
    background: var(--pool);
    mix-blend-mode: color;
    opacity: var(--rail-tone-strength);
    pointer-events: none;
  }
}

@supports (mix-blend-mode: lighten) and (mix-blend-mode: darken) {

  /* DUOTONE. Greyscale first, then both ends of the ramp are replaced.
     Contrast stays at the house value — the ramp does the work. */
  .rail-tone[data-tone='duotone'] img {
    filter: grayscale(1) contrast(var(--ph-con));
  }

  .rail-tone[data-tone='duotone']::before,
  .rail-tone[data-tone='duotone']::after {
    content: '';
    position: absolute;
    inset: 0;
    opacity: var(--rail-tone-strength);
    pointer-events: none;
  }

  /* `lighten` floors every channel at the ink, so nothing in the frame ends
     up darker than it: the shadows go navy. */
  .rail-tone[data-tone='duotone']::before {
    background: var(--ink);
    mix-blend-mode: lighten;
  }

  /* `darken` ceilings every channel at the gold, so nothing ends up lighter:
     the highlights go to the accent, which is the panel chrome's gold.

     MEASURED, AND KNOW THIS BEFORE TUNING IT: a lighten/darken pair recolours
     only the two ENDS of the ramp. Everything between the floor and the
     ceiling passes through as the greyscale value it already was, so the
     mid-tones take no colour from either layer. On top of that --accent's
     blue channel (0x1E) sits BELOW --ink's (0x26), so the darken layer clamps
     blue flat across the whole frame and the mids land on #80801E — olive.
     Swapping --ink for --plate does not help; its blue is higher still.
     Measured mean hue of the frame at strength .85 is 71deg, not navy.
     A ramp that actually runs navy-to-gold needs a different pair of blend
     modes, which is a design decision and Kearnan's to make. */
  .rail-tone[data-tone='duotone']::after {
    background: var(--accent);
    mix-blend-mode: darken;
  }
}

/* Forced colours. Stacked blend layers are exactly what breaks here — the
   backgrounds are forced to a system colour and the photograph vanishes
   under a solid plate. Both layers go, and the image keeps the same
   treatment every other photograph on the page has. */
@media (forced-colors: active) {
  .rail-tone[data-tone]::before,
  .rail-tone[data-tone]::after { display: none; }
  .rail-tone[data-tone] img { filter: var(--photo); }
}

/* --- Friend count --------------------------------------------------------
   On MySpace this counted friends. Here it counts the six entries. ------- */

.friendcount {
  margin: 0;
  padding: 4px var(--pad);
  border-bottom: 1px solid var(--rule-soft);
  background: var(--stripe);
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  color: var(--ink-soft);
}

/* --- Shared table body --------------------------------------------------
   Used by About (and by Upcoming when it is back). ---------------------- */

.panel__body--flush { padding: 0; }

/* --- The player ----------------------------------------------------------
   Period panel, real controls. The two range inputs are native on purpose:
   arrow keys, Home/End, focus and ARIA all arrive for free, which is
   exactly what hand-rolled div sliders lose.

   Both ranges are styled through a shared --fill custom property that the
   script updates, so the filled portion is drawn by a gradient on the track
   rather than by a second element.
   -------------------------------------------------------------------------- */

/* The track name is a stylisation — DOLM3NS_HARP was caps and underscore and
   slangin is lower case, both as Kearnan wrote them. text-transform is pinned
   to none so no inherited rule can sentence-case it or otherwise tidy it up.
   The element was out of index.html for two tracks that had no name from him;
   it is back, and this is why it may not be capitalised on the way. */
.player__title {
  margin: 0 0 var(--pad-tight);
  font-weight: bold;
  line-height: var(--lh-tight);
  text-transform: none;
  font-variant: none;
  letter-spacing: .01em;
}

.player__row {
  display: flex;
  align-items: center;
  gap: var(--pad-tight);
  flex-wrap: wrap;
}
.player__row + .player__row { margin-top: var(--pad-tight); }

.player__btn {
  flex: 0 0 auto;
  min-width: 26px;
  height: 20px;
  padding: 0 5px;
  background: var(--paper);
  border: 2px solid var(--plate);
  box-shadow:
    inset 2px 2px 0 var(--shine-hi),
    inset -2px -2px 0 color-mix(in srgb, var(--plate) 28%, transparent);
  color: var(--ink);
  font: var(--fs-data)/1 var(--font-ui);
  cursor: pointer;
}
.player__btn:hover { color: var(--link-hover); }
.player__btn:active { box-shadow: inset 2px 2px 0 color-mix(in srgb, var(--plate) 28%, transparent); }

.player__time {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
}

/* --- The seek bar -------------------------------------------------------- */

.player__seek {
  flex: 1 1 90px;
  min-width: 70px;
  height: 20px;
  margin: 0;
  padding: 0;
  background: transparent;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}

.player__seek::-webkit-slider-runnable-track {
  height: 10px;
  background:
    linear-gradient(90deg, var(--accent) 0 var(--fill, 0%), transparent var(--fill, 0%)),
    var(--plate);
  border: 2px solid var(--plate);
}
.player__seek::-moz-range-track {
  height: 10px;
  background:
    linear-gradient(90deg, var(--accent) 0 var(--fill, 0%), transparent var(--fill, 0%)),
    var(--plate);
  border: 2px solid var(--plate);
}
.player__seek::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 8px;
  height: 16px;
  margin-top: -5px;
  background: var(--shine-hi);
  border: 2px solid var(--plate);
}
.player__seek::-moz-range-thumb {
  width: 8px;
  height: 16px;
  border-radius: 0;
  background: var(--shine-hi);
  border: 2px solid var(--plate);
}

.player__seek:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
  box-shadow: 0 0 0 4px var(--focus-back);
}

/* The control announces itself whenever the audio is NOT going to start on
   its own — whether because we are waiting for a first interaction, or
   because a remembered pause, a remembered mute or prefers-reduced-motion
   has suppressed the autostart.

   This is a fix for a real fault, not decoration. Suppression was invisible:
   a visitor who had once pressed pause got a player reading "Play",
   "mute" and "0:00 / 2:16" on every later visit, silent, with nothing
   anywhere saying why. That is indistinguishable from broken, and it is
   permanent, because the thing that would tell them is the sound.

   A ring rather than a recoloured glyph, so legibility is not spent on it. */
.player[data-armed='true'] [data-play],
/* Pressed, but no sound out of it yet.

   The track is not fetched until somebody asks for it, so the first press
   has a wait on a slow connection. A control that does nothing for a second
   reads as broken — that has happened here before — so the button says it is
   working. It blinks, which is animation, so under prefers-reduced-motion it
   holds a dimmed state instead. No new wording anywhere: the label stays
   'Play' because there is no approved copy for this. */
.player[data-state='waking'] [data-play] {
  color: var(--accent);
  animation: player-waking 760ms steps(1, end) infinite;
}
@keyframes player-waking {
  0%  { opacity: 1; }
  50% { opacity: .3; }
}

.player[data-autostart='suppressed'][data-state='paused'] [data-play] {
  border-color: var(--accent);
  box-shadow:
    inset 2px 2px 0 var(--shine-hi),
    0 0 0 2px color-mix(in srgb, var(--accent) 60%, transparent);
}

/* --- The moving sign ------------------------------------------------------
   What used to be the three About paragraphs. Kearnan's brief: a scrolling
   sign in digital-clock type, reading "welcome to the simulation: choose
   your character" (the second half added 8 Oct 2026), built to
   look like the top navigation bar of a mid-2000s social network — "eerily
   nostalgic". The old copy is in data/about-copy.retired.json.

   THE IDIOM, NOT THE ARTEFACT. Nothing here reproduces anybody's bar: no
   wordmark, no lifted gradient, no traced chrome. What is period is the
   convention — a dark strip, a lamp at the left, small tight type, hard
   bevelled edges — and every value comes from this site's own tokens, so it
   sits with the furniture rather than arriving from outside.

   NO GRADIENT, deliberately. CLAUDE.md gives the section header bar the one
   gradient allowed anywhere on the site, so the gloss here is done the way
   .panel does it: hard 1px bevels, light above and dark below, no blur. The
   text therefore sits on flat --plate, which is the point below.

   IT ADDS NO COLOUR PAIR. --accent on --plate is already measured by
   tools/check-contrast.mjs as the INSERT COIN marquee, at 4.5. Keeping the
   sign's surface flat rather than washed is what lets it borrow that pair
   honestly instead of putting text on a gradient nothing can measure.
   -------------------------------------------------------------------------- */

.sign {
  display: flex;
  align-items: stretch;
  /* The mask. Everything the run does happens behind this, which is also
     what stops a 25-character sentence widening the page at 400px. */
  overflow: hidden;
  min-width: 0;
  background: var(--plate);
  border-top: 1px solid color-mix(in srgb, var(--plate) 55%, var(--pool));
  border-bottom: 1px solid var(--sky-top);
  box-shadow:
    inset 0  1px 0 color-mix(in srgb, var(--plate) 70%, var(--sky-low)),
    inset 0 -1px 0 var(--sky-top);
}

/* The lamp at the left. Period nav bars all had something pinned there, and
   this site already owns the motif: the same 5px accent block that flanks
   every section heading. It does not blink — one moving thing on this panel
   is the whole idea, and a second would only be noise. */
.sign__lamp {
  flex: 0 0 auto;
  align-self: center;
  width: 5px; height: 5px;
  margin: 0 var(--pad-tight) 0 var(--pad);
  background: var(--accent);
  box-shadow: 0 0 0 1px var(--sky-top);
}

/* The window the sign travels behind. */
.sign__window {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  padding: 6px 0;
}

/* The run: both copies on one line, sized to their own content so that
   -50% is exactly one copy's travel. */
.sign__run {
  display: flex;
  width: max-content;
  margin: 0;
  color: var(--accent);
  font-family: var(--font-sign);
  font-size: 13px;
  line-height: 1.25;
  letter-spacing: .04em;
  white-space: nowrap;
  /* 46s, up from 26s, when the sentence grew from "welcome to the
     simulation" to "...: choose your character" on 8 Oct 2026. The travel
     is one copy's width, so a longer copy at the same duration would run
     nearly twice as fast; 46s keeps the old speed, about 11px a second. */
  animation: sign-run 46s linear infinite;
}

/* The gap between the two copies belongs to the copy, not to the flex row —
   a `gap` would sit between them but not after the second one, and the loop
   would hitch by one gap every time it wrapped. */
.sign__say { padding-right: 3.5em; }

/* Linear and unending, like every sign of this kind. Nothing eases. */
@keyframes sign-run {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* Phone. The sentence has to sit inside the window with the scroll STOPPED,
   so the type comes down rather than relying on travel to reveal itself. */
@media (max-width: 430px) {
  .sign__run { font-size: 11px; letter-spacing: .02em; }
  .sign__lamp { margin-left: var(--pad-tight); }
}

/* ==========================================================================
   THE SIGN STOPS. Not slower — stopped, per the house rule that motion here
   is off rather than reduced.

   Stopping a marquee usually leaves it mid-sentence, so two more things
   happen: the spare copy is removed, and the one that is left is centred in
   its window. What a reader gets is an ordinary, static, fully legible line
   of sign type. Legibility never depended on the travel.

   ONE TREATMENT, THREE TRIGGERS, and they are written twice below only
   because a media query cannot join a selector list. Keep them in step.

     1. prefers-reduced-motion. The system asked for stillness.
     2. [data-motion='paused'] on <html>. The visitor pressed the footer's
        pause control — see js/motion.js. This is the CSS half of that
        control: script-driven motion registers with it, and motion drawn in
        CSS reads this attribute instead, because a stylesheet cannot
        register anything. A new CSS animation that should stop when the
        visitor says stop is added HERE, to this selector, and does not get
        a control of its own.
     3. html:not(.js). With scripting off there is no control to press — and
        no rune field either, since js/runes.js never runs — so the one
        thing left that moves is stopped outright rather than left running
        with nothing able to halt it. The stopped sign is fully legible, so
        this costs a reader nothing.

   The freeze is deliberately NOT `animation-play-state: paused`. That would
   stop the run wherever it happened to be, which is usually mid-word. The
   site already has a designed stopped state for this sign and the pause
   control uses it.
   ========================================================================== */

:root[data-motion='paused'] .sign__run,
html:not(.js) .sign__run {
  animation: none;
  transform: none;
  width: 100%;
  justify-content: center;
  /* The stopped line may wrap. At 48 characters it is wider than the
     window on a phone, and a stopped sign that is cut off is not legible. */
  white-space: normal;
  text-align: center;
}
:root[data-motion='paused'] .sign__say,
html:not(.js) .sign__say { padding-right: 0; }
:root[data-motion='paused'] .sign__say[aria-hidden='true'],
html:not(.js) .sign__say[aria-hidden='true'] { display: none; }

/* THE INSERT COIN BANNER JOINS HERE, and this is the whole of its
   registration. It is a CSS animation, so it reads the attribute rather
   than calling register() — js/motion.js stays a pause registry with no
   timer, nothing to subscribe to and nothing new hanging off it.

   It qualifies for WCAG 2.2.2 now in a way it did not before: as a holding
   state it blinked for a few seconds and gave way, and as a permanent
   banner it blinks indefinitely beside other content.

   The stopped look is NOT a second design. It is the same two declarations
   prefers-reduced-motion already gives it, further down this file — the
   marquee simply stays lit. Nothing is frozen with animation-play-state,
   which would leave it stopped dark half the time.

   Only two of the sign's three triggers are needed. html:not(.js) is
   already covered: the whole panel is display:none without scripting, so
   there is nothing blinking to stop. */
:root[data-motion='paused'] .char__coin { animation: none; opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .sign__run {
    animation: none;
    transform: none;
    width: 100%;
    justify-content: center;
    white-space: normal;
    text-align: center;
  }
  .sign__say { padding-right: 0; }
  .sign__say[aria-hidden='true'] { display: none; }
}

/* --- Top 6 ---------------------------------------------------------------- */

.top6 {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--pad-loose) var(--pad);
  align-items: stretch;   /* tiles with less metadata still fill the row */
}

.tile { min-width: 0; }

.tile__link {
  display: block;
  text-decoration: none;
  color: inherit;
}

.tile__cover {
  border: 1px solid var(--rule);
}

.tile__link { text-align: center; }
.tile__name {
  display: block;
  margin-top: var(--pad-tight);
  color: var(--link);
  text-decoration: underline;
  text-underline-offset: 2px;
  line-height: var(--lh-tight);
}

.tile__meta {
  display: block;
  margin-top: 2px;
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  line-height: var(--lh-tight);
  color: var(--ink-soft);
}
/* Not every entry has a date and a venue — one is a set of portraits with
   neither, and that is the ordinary case, not an exception. Two things keep
   the grid even regardless:

     1. the tiles stretch to the row height, and the name/meta block is
        pushed to the top, so a shorter tile does not float;
     2. the venue line, WHEN PRESENT, reserves two lines, because venue
        strings wrap to two often enough that reserving stops the row
        jumping about.

   A tile with no venue line simply ends after the photo count. There is no
   empty element and no reserved gap for it. */
.tile__link { height: 100%; }
.tile__meta + .tile__meta { min-height: calc(2em * var(--lh-tight)); }

/* Hover pushes the processing harder still and the name takes the accent. */
.tile__link:hover .tile__cover img,
.tile__link:focus-visible .tile__cover img { filter: var(--photo-hover); }

.tile__link:hover .tile__name,
.tile__link:focus-visible .tile__name { color: var(--link-hover); }

/* --- Inline albums (the no-JS layer) -------------------------------------
   Real, linked content: every photograph of every entry, reachable without
   scripting. album.js hides these and upgrades the tiles into the viewer.
   -------------------------------------------------------------------------- */

.albums > * + * { margin-top: var(--gap); }

.js .albums { display: none; }

.album__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--pad);
  flex-wrap: wrap;
}

.album__title {
  font-family: var(--font-display);
  font-size: var(--fs-album);
  text-transform: uppercase;
  letter-spacing: var(--track-display);
  line-height: 1.1;
}

/* --- The album grid, for any count from six to twenty --------------------
   An entry carries 6-20 photographs. Letting a fixed 4-column grid handle
   all of them leaves ragged last rows: 9 becomes 4+4+1, 6 becomes 4+2, and
   a single orphan on the last row reads as a mistake rather than a layout.

   So the column count is chosen per total, by one rule applied to every
   number in the range rather than by taste:

     - columns stay between 3 and 5. Below 3 the grid stops being a grid;
       above 5 the tile drops under ~110px in the 620px main column and the
       photograph stops reading.
     - if the total divides evenly by one of those, take the LARGEST that
       does, so the rows are even and the grid stays compact.
     - otherwise take the column count leaving the FULLEST last row, and
       never one that leaves a single orphan. Ties go to the larger count.

       6  -> 3 cols   3+3               even
       7  -> 4 cols   4+3               one short, unavoidable with 7
       8  -> 4 cols   4+4               even
       9  -> 3 cols   3+3+3             even  (4 cols would orphan one)
      10  -> 5 cols   5+5               even
      11  -> 4 cols   4+4+3             one short, unavoidable with 11
      12  -> 4 cols   4+4+4             even
      13  -> 5 cols   5+5+3             3 and 4 both orphan one
      14  -> 5 cols   5+5+4             one short
      15  -> 5 cols   5+5+5             even
      16  -> 4 cols   4+4+4+4           even
      17  -> 5 cols   5+5+5+2           three short, the best 17 allows
      18  -> 3 cols   3 x 6             even  (the only even fit under 6 cols)
      19  -> 5 cols   5+5+5+4           one short
      20  -> 5 cols   5+5+5+5           even

   7, 11, 13, 17 and 19 are prime and cannot divide evenly at any sensible
   column count, so they take the arrangement with the fullest last row.
   18 is the one number in the range whose only even fit is 3 columns, so
   the longest album on the site is also the one with the largest tiles.

   Twenty is the ceiling, and it is the same ceiling as tools/sync-html.mjs
   and tools/make-photos.mjs. Past it the grid falls back to 4 columns, which
   is a ragged last row rather than a broken page — but sync-html refuses the
   album before it can get here.
   -------------------------------------------------------------------------- */

.album__grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--pad);
  margin-top: var(--pad);
}

.album__grid[data-count='6'],
.album__grid[data-count='9'],
.album__grid[data-count='18'] { grid-template-columns: repeat(3, 1fr); }

.album__grid[data-count='10'],
.album__grid[data-count='13'],
.album__grid[data-count='14'],
.album__grid[data-count='15'],
.album__grid[data-count='17'],
.album__grid[data-count='19'],
.album__grid[data-count='20'] { grid-template-columns: repeat(5, 1fr); }

.album__grid figure { margin: 0; }
.album__grid a { display: block; border: 1px solid var(--rule); }
.album__grid figcaption {
  margin-top: 3px;
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  color: var(--ink-soft);
  line-height: var(--lh-tight);
}

.album__back { margin-top: var(--pad); }

/* ==========================================================================
   The album viewer. Built by js/album.js, styled here.
   ========================================================================== */

.viewer[hidden] { display: none; }

.viewer {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: color-mix(in srgb, var(--scrim) 100%, transparent);
  background: color-mix(in srgb, var(--scrim) 94%, transparent);
  overflow: auto;
}

/* Focus lands here on open so the dialog is announced. It is a container,
   not a control — the window appearing is the visible indication. */
.viewer__window:focus,
.viewer__window:focus-visible { outline: none; }

.viewer__window {
  position: relative;
  width: min(940px, 100%);
  margin: auto;
  background: var(--chrome);
  border: 1px solid var(--rule);
  box-shadow: inset 1px 1px 0 var(--bevel);
}

.viewer__bar {
  display: flex;
  align-items: center;
  gap: var(--pad-tight);
  padding: 2px var(--pad-tight) 1px;
  background: linear-gradient(180deg, var(--bar) 0%, var(--bar-lo) 100%);
  border-bottom: 1px solid var(--bar-lo);
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bar) 62%, var(--paper));
  color: var(--bar-ink);
}

.viewer__title {
  font-family: var(--font-display);
  font-size: var(--fs-album);
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: var(--track-display);
}

.viewer__venue {
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  opacity: .9;
}

.viewer__close {
  margin-left: auto;
  width: 20px; height: 18px;
  padding: 0;
  background: transparent;
  border: 1px solid color-mix(in srgb, var(--bar) 40%, var(--paper));
  color: var(--bar-ink);
  font: 12px/1 var(--font-ui);
  cursor: pointer;
}
.viewer__close:hover { color: var(--accent); border-color: var(--accent); }

.viewer__stage {
  display: grid;
  grid-template-columns: 62px 1fr 62px;
  align-items: center;
  gap: var(--pad);
  padding: var(--pad-loose);
  background: var(--paper);
}

.viewer__frame {
  position: relative;
  display: block;
  min-width: 0;
  background: var(--ink);
  border: 1px solid var(--rule);
  line-height: 0;
}

.viewer__frame img {
  display: block;
  width: 100%;
  max-height: min(62vh, 560px);
  object-fit: contain;
  margin: 0 auto;
  filter: var(--photo);
  transition: opacity var(--fade) linear;
}
.viewer__frame[data-fading='true'] img { opacity: 0; }

.viewer__nav {
  background: transparent;
  border: 0;
  padding: var(--pad-tight) 0;
  color: var(--link);
  font: var(--fs-body)/var(--lh-tight) var(--font-ui);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
.viewer__nav:hover:not(:disabled) { color: var(--link-hover); }
.viewer__nav:disabled { color: color-mix(in srgb, var(--ink) 45%, var(--paper)); text-decoration: none; cursor: default; }
.viewer__nav--next { text-align: right; }

.viewer__caption {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--pad);
  padding: 0 var(--pad-loose) var(--pad-loose);
  background: var(--paper);
}
.viewer__caption p { margin: 0; }
.viewer__counter {
  flex: 0 0 auto;
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  color: var(--ink-soft);
}

/* --n is the COLUMN count, set by js/album.js: one column per photograph up
   to twelve, and past that as few even rows as hold the album — eighteen is
   9 + 9. The fallback of 12 only applies if the property is somehow missing.
   Narrow widths override it outright below. */
.viewer__strip {
  display: grid;
  grid-template-columns: repeat(var(--n, 12), 1fr);
  gap: var(--pad-tight);
  padding: var(--pad) var(--pad-loose) var(--pad-loose);
  background: var(--paper);
  border-top: 1px solid var(--rule-soft);
}

.viewer__thumb {
  display: block;
  padding: 0 0 3px;
  background: transparent;
  border: 0;
  border-bottom: 2px solid transparent;
  cursor: pointer;
  line-height: 0;
}

.viewer__thumb .shot { border: 1px solid var(--rule); }
.viewer__thumb[aria-current='true'] { border-bottom-color: var(--accent-3); }
.viewer__thumb:not([aria-current='true']) > .shot img { filter: var(--photo) opacity(.72); }
.viewer__thumb:hover > .shot img { filter: var(--photo-hover); }

/* ==========================================================================
   The INSERT COIN window. Built by js/coin.js, styled here.

   It wears the album viewer's chrome token for token — same scrim, same
   window box, same header bar, same close × — because this site has one
   dialog pattern and a second one would be a second pattern. The rules are
   written out rather than bolted onto the viewer's selectors so that
   nothing here can change the album by accident.

   IT IS DELIBERATELY EMPTY. The body is a made, sized, bevelled space with
   nothing in it, because the position and the interaction are what is being
   judged before the form exists. It is NOT a loading state: no spinner, no
   skeleton, nothing that implies something is on its way. A reader who can
   see it gets an empty cabinet screen; what a screen reader gets is a
   dialog with a heading and a close button and no explanation, which is
   recorded in the log as the one thing about this that reads badly.
   ========================================================================== */

.coin[hidden] { display: none; }

.coin {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: color-mix(in srgb, var(--scrim) 94%, transparent);
  overflow: auto;
}

/* Focus lands here on open so the dialog is announced. It is a container,
   not a control — the window appearing is the visible indication. */
.coin__window:focus,
.coin__window:focus-visible { outline: none; }

.coin__window {
  position: relative;
  width: min(420px, 100%);
  margin: auto;
  background: var(--chrome);
  border: 1px solid var(--rule);
  box-shadow: inset 1px 1px 0 var(--bevel);
}

.coin__bar {
  display: flex;
  align-items: center;
  gap: var(--pad-tight);
  padding: 2px var(--pad-tight) 1px;
  background: linear-gradient(180deg, var(--bar) 0%, var(--bar-lo) 100%);
  border-bottom: 1px solid var(--bar-lo);
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bar) 62%, var(--paper));
  color: var(--bar-ink);
}

/* Verdana, not Archivo Narrow. The display face is used in exactly three
   places on this site and a fourth is not being opened here — this heading
   is the banner's own type, tracked out the same way, so the window reads
   as the thing the marquee opened. */
.coin__title {
  font-size: 11px;
  letter-spacing: .22em;
  text-indent: .22em;
}

.coin__close {
  margin-left: auto;
  width: 20px; height: 18px;
  padding: 0;
  background: transparent;
  border: 1px solid color-mix(in srgb, var(--bar) 40%, var(--paper));
  color: var(--bar-ink);
  font: 12px/1 var(--font-ui);
  cursor: pointer;
}
.coin__close:hover { color: var(--accent); border-color: var(--accent); }

/* The empty space, and it is a space rather than an absence: a sized cel
   fill with the panel's own hard inset bevel on it. */
.coin__body {
  min-height: 190px;
  background: var(--paper);
  box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel);
}

/* --- Site foot -----------------------------------------------------------
   On its own plate (layout.css), so it takes --plate-ink. The old footer
   sat straight on the background at 3.90:1 — a fail, and it is where the
   only contact link on the site lives. */

.foot a,
.namebar a { color: var(--plate-ink); }
.namebar a:hover,
.namebar a:focus-visible { color: var(--accent); text-decoration-thickness: 2px; }
.foot a:hover,
.foot a:focus-visible { color: var(--accent); text-decoration-thickness: 2px; }

/* --- Footer controls: text size, and the pause control --------------------
   Two controls, one vocabulary. The pause control was built to the
   text-size control's pattern on purpose — it is the nearest existing thing
   in the page chrome, it was already keyboard reachable and labelled, and
   it already remembered a choice — so the two share every rule here and
   differ only in what they say.

   NEITHER ADDS A COLOUR PAIR. --plate-ink on --plate and --accent on
   --plate are both already measured by tools/check-contrast.mjs, and the
   pressed state is a border and a text colour rather than a fill, so
   nothing new has to be checked. The reduced-motion state of the pause
   control is deliberately NOT dimmed for the same reason: a mixed-down ink
   would be an unmeasured pair, so it keeps the pressed accent and says why
   in words instead.
   -------------------------------------------------------------------------- */

.textsize,
.motion { display: flex; align-items: baseline; gap: var(--pad-tight); }
.textsize button,
.motion button {
  padding: 1px 5px;
  background: transparent;
  border: 1px solid color-mix(in srgb, var(--plate-ink) 44%, transparent);
  color: var(--plate-ink);
  font-family: var(--font-ui);
  cursor: pointer;
}
.textsize button[aria-pressed='true'],
.motion button[aria-pressed='true'] {
  border-color: var(--accent);
  color: var(--accent);
}
.textsize button:nth-of-type(1) { font-size: 10px; }
.textsize button:nth-of-type(2) { font-size: 13px; }
.motion button { font-size: 10px; }

/* Held still by the system setting: pressed, and not a thing to press. It
   keeps its focus ring and stays in the tab order — aria-disabled, not
   disabled — so the reason beside it can actually be reached and read. */
.motion button[aria-disabled='true'] { cursor: default; }
.motion__why { color: var(--plate-soft); }

/* With scripting off the control cannot work, so it is not shown. Nothing
   is lost: no rune field is ever created without script and the sign is
   stopped in CSS, so there is nothing left on the page to pause. */
html:not(.js) .motion { display: none; }

/* ==========================================================================
   Responsive
   ========================================================================== */

@media (max-width: 1023px) {
  .top6 { grid-template-columns: repeat(2, 1fr); }
  /* [data-count] to match the specificity of the count rules above, which
     would otherwise win and keep a 5-column grid on a narrow screen.

     Three columns is the base here because the main column is fluid below
     1024px and five would be 70px tiles at the bottom of the band. Three
     divides 6, 9, 12, 15 and 18 evenly, but it orphans one photograph on
     every count of the form 3n+1 — so 7, 10, 16 and 19 take four columns
     instead, where the last row comes out full or nearly so.

     13 is the exception and it is left orphaned deliberately: three leaves
     3+3+3+3+1 and four leaves 4+4+4+1, and the only orphan-free fit is five,
     which is too narrow to use across this whole band. It is the one count
     between 6 and 20 that this width cannot lay out evenly. */
  .album__grid,
  .album__grid[data-count] { grid-template-columns: repeat(3, 1fr); }

  .album__grid[data-count='7'],
  .album__grid[data-count='10'],
  .album__grid[data-count='16'],
  .album__grid[data-count='19'] { grid-template-columns: repeat(4, 1fr); }

  .viewer__strip { grid-template-columns: repeat(6, 1fr); }
}

@media (max-width: 560px) {
  /* One column: the name centred on the bar, the status centred under it. */
  .namebar__row { grid-template-columns: 1fr; justify-items: center; gap: 4px; }
  .namebar__name,
  .namebar__status { grid-column: 1; justify-self: center; }
  .viewer { padding: 10px; }
  .viewer__stage {
    grid-template-columns: 1fr 1fr;
    grid-template-areas: 'frame frame' 'prev next';
  }
  .viewer__frame { grid-area: frame; }
  .viewer__nav--prev { grid-area: prev; }
  .viewer__nav--next { grid-area: next; }
  .viewer__caption { flex-direction: column; gap: 2px; }
}

@media (max-width: 380px) {
  .top6 { grid-template-columns: 1fr; }
  /* Two columns for every count, including the odd ones. An odd total leaves
     a last row of one here, and that is the right trade at 320px: the tile
     stays about 140px and readable, and a lone tile under rows of TWO reads
     as the leftover of a pair rather than as the 1-in-5 orphan the desktop
     table exists to prevent. Three columns would fix the arithmetic and take
     every photograph on the page down to about 90px. */
  .album__grid,
  .album__grid[data-count] { grid-template-columns: repeat(2, 1fr); }
  .viewer__strip { grid-template-columns: repeat(4, 1fr); }
  .namebar { padding: var(--pad-tight); }
  .namebar__name { font-size: 20px; }
}

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

/* ==========================================================================
   THE CHROME BEVEL

   Display type — profile name, section header bars, album titles — gets a
   hard outline, a metallic gradient fill and a drop shadow. Pure CSS, no
   images, per CLAUDE.md.

   Two safety rails, both deliberate:

     1. It is wrapped in @supports. `background-clip: text` needs a
        transparent text colour to show, and if the declaration is not
        understood the text goes invisible. The solid --plate-ink /
        --bar-ink fill set earlier is the fallback and stays put.
     2. Chrome is only ever applied over a dark surface — the name bar and
        the header bars are both plates. A light metallic gradient cannot
        carry contrast on a light panel, so it is never asked to.
   ========================================================================== */

@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .namebar__name,
  .panel__head .panel__label,
  .viewer__title {
    background-image: linear-gradient(
      180deg,
      var(--shine-hi)  0%,
      var(--shine-mid) 38%,
      var(--shine-lo)  49%,
      var(--shine-lo)  51%,
      var(--shine-mid) 60%,
      var(--shine-hi)  86%,
      var(--shine-mid) 100%
    );
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    -webkit-text-stroke: 1px var(--plate);
    paint-order: stroke fill;
  }

  .namebar__name {
    -webkit-text-stroke-width: 2px;
    filter:
      drop-shadow(2px 2px 0 var(--plate))
      drop-shadow(0 0 7px var(--accent));
  }

  /* Section header labels are 12px. Two things that work at 30px do not
     work at 12px and both were caught on screen, not in theory:

       - a 1px stroke eats most of a 12px glyph, so it drops to 0.5px
       - the dark --shine-lo band across the middle of the name gradient
         lands as a dark bar through the middle of small text on an
         already dark header, and the label stops being legible

     So small display type takes a LIGHT-ONLY metallic gradient. It still
     reads as chrome — highlight, body, highlight — but every stop stays
     bright against the bar. Per CLAUDE.md the fix is to move the text, not
     to lighten the bar, and this is that fix. */
  .panel__head .panel__label,
  .viewer__title {
    background-image: linear-gradient(
      180deg,
      var(--shine-hi)  0%,
      var(--shine-mid) 48%,
      var(--shine-hi)  62%,
      var(--shine-mid) 100%
    );
    -webkit-text-stroke: 0.5px var(--bar-lo);
    filter: drop-shadow(1px 1px 0 var(--bar-lo));
    text-shadow: none;
  }
}

/* ==========================================================================
   The overlay layer

   A halftone screen above album images. A class on the frame rather than a
   global, so it can be pulled back per context — it is on the album grid
   and the viewer's main frame, and deliberately NOT on the Top 6 covers or
   the thumbnail strip, where a dozen or more at once turns into noise and
   nothing reads.

   Drawn in white and blended, so it takes its colour from whatever is
   underneath. Its strength is a face-safety decision, not a taste one: at
   .60 the dots land on skin and read as a blemish. See CLAUDE.md.
   ========================================================================== */

.overlay::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 3;
  background: url('/img/skin/halftone.svg') repeat 0 0;
  background-size: 12px;
  mix-blend-mode: overlay;
  opacity: .30;
  pointer-events: none;
}

/* ==========================================================================
   Period furniture
   ========================================================================== */

/* --- Mood ---------------------------------------------------------------
   Played completely straight, per CLAUDE.md. The emoticon is text, not an
   image, and is hidden from screen readers — ":: D" read aloud in the
   middle of a sentence is noise, and the mood word carries the meaning. */

.mood {
  margin: var(--pad-tight) 0 0;
  font-size: var(--fs-data);
  letter-spacing: var(--track-data);
  color: var(--plate-soft);
}
.mood__value { color: var(--plate-ink); }
.mood__face {
  margin-left: 5px;
  color: var(--accent);
  font-family: var(--font-ui);
  letter-spacing: 0;
}

/* ==========================================================================
   Reduced motion. The skin swap and the album cross-fade both become
   instant. Nothing on this page animates on load in the first place.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  .shot img,
  .viewer__frame img { transition: none; }
}

/* ==========================================================================
   THE LOOK

   One visual language, not an average of two. The furniture stays busy and
   period-correct — that is what survives from the MySpace/Bebo direction —
   but every surface, edge, fill and texture below is drawn in the
   screen-era grammar of the reference board.

   Nothing here reproduces anything from that board. Every figure on it is
   someone else's copyrighted character, so no figure is drawn at all. What
   is taken is the grammar: pixel edges, flat cel fills, airbrushed sky,
   radial bloom, invented glyphs, palette.

   Nothing here animates in CSS, so `prefers-reduced-motion` has nothing in
   this section to switch off. The bloom, the aura and the glyph frieze are
   all static, which is also what they are on a screen grab.

   ONE THING IN THIS SECTION DOES MOVE, and it is not moved from here: the
   falling rune field below is a canvas driven by js/runes.js, and under
   reduced motion that script draws it as a still at the same dimness rather
   than being switched off. Its rule set carries the note.
   ========================================================================== */

/* --- Pixel edges ---------------------------------------------------------
   Sprite art has no curves and no blur. Corners are cut in whole pixel
   steps and shadows are hard offsets, never soft. --px is the step. */

.panel,
.namebar,
.foot {
  border: 2px solid var(--plate);
  box-shadow:
    var(--px) var(--px) 0 var(--plate),
    inset 1px 1px 0 var(--bevel);
}

/* The stepped corner: two notches rather than a radius. */
.panel {
  clip-path: polygon(
    var(--px) 0, calc(100% - var(--px)) 0, 100% var(--px),
    100% calc(100% - var(--px)), calc(100% - var(--px)) 100%,
    var(--px) 100%, 0 calc(100% - var(--px)), 0 var(--px));
}

.canvas {
  border: 3px solid var(--plate);
  box-shadow:
    6px 6px 0 color-mix(in srgb, var(--plate) 55%, transparent),
    inset 1px 1px 0 var(--bevel);
}

/* Header bars keep a hard rule under them rather than a gradient edge. */
.panel__head { border-bottom: 2px solid var(--plate); }

/* The blocks either side of a heading become 16-bit pixel marks: a solid
   square with a lighter top-left pixel, which is how a sprite fakes a
   light source without a gradient. */
.panel__head::before,
.panel__head::after {
  width: 6px; height: 6px;
  flex: 0 0 6px;
  background: var(--accent);
  box-shadow:
    inset 2px 2px 0 var(--shine-hi),
    0 0 0 1px var(--bar-lo);
}

/* --- The energy bloom ----------------------------------------------------
   The board's strongest single motif: a blinding white core with a cyan
   halo spreading out. It sits behind the name bar, with its origin to the
   right so it never washes out the mood line or the last-login stamp, which
   are left-aligned, or the name, which has been centred since 8 Oct 2026
   and ends well short of the core. Rays or bloom behind small
   --plate-soft text would eat contrast locally, and a checker that
   measures flat colours would never catch it. */

.namebar {
  position: relative;
  overflow: hidden;
}
.namebar::before {
  content: '';
  position: absolute;
  inset: -40% -5% -40% 30%;
  z-index: 0;
  background:
    radial-gradient(circle at 74% 50%,
      var(--shine-hi) 0%,
      color-mix(in srgb, var(--shine-hi) 78%, transparent) 6%,
      color-mix(in srgb, var(--accent-2) 62%, transparent) 16%,
      color-mix(in srgb, var(--accent-2) 26%, transparent) 34%,
      transparent 62%);
  pointer-events: none;
}
.namebar > * { position: relative; z-index: 1; }

/* --- The gold aura -------------------------------------------------------
   The board's other energy note. Kept on display type, where it was
   already working. */

.namebar__name {
  filter:
    drop-shadow(2px 2px 0 var(--plate))
    drop-shadow(0 0 9px var(--accent))
    drop-shadow(0 0 20px color-mix(in srgb, var(--accent) 55%, transparent));
}

.panel__head .panel__label,
.viewer__title {
  filter:
    drop-shadow(2px 2px 0 var(--bar-lo))
    drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 60%, transparent));
}

/* --- The glyph frieze ----------------------------------------------------
   An ORIGINAL alphabet, generated by tools/make-skins.mjs, running as a
   band across the top of the canvas. The board has an invented script on
   one panel, and the reason that idea is safe to take is precisely that we
   invent our own letters: these correspond to nothing and transliterate to
   nothing. Decoration, and hidden from assistive technology. */

.canvas { position: relative; }
.canvas::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 26px;
  z-index: 0;
  background: url('/img/skin/glyphs.svg') repeat-x 0 50%;
  opacity: .5;
  pointer-events: none;
}
.canvas > * { position: relative; z-index: 1; }

/* --- The falling rune field ----------------------------------------------
   The same alphabet, falling. One <canvas>, created by js/runes.js, fixed
   to the viewport so the runes keep falling as the page scrolls rather than
   scrolling with it. The frieze above is the BASE STATE and this is an
   enhancement behind it: with scripting off no canvas is ever created and
   the page is unchanged.

   WHERE IT SITS IN THE STACK, and the negative z-index is the whole of it.
   A negative-index child paints after the root's background and before any
   in-flow content, so the order down the page is: the sky gradient on
   <html>, then the runes, then body's three cloud layers, then the canvas,
   the panels and the footer — all of which have opaque fills. The runes
   therefore sit behind the clouds as well as behind the content, which is
   the depth they want, and no text on this page is ever drawn over them.
   Give this a z-index of 0 and it would paint over the footer.

   THE DIMNESS KNOB IS THIS ONE LINE AND THERE IS NO OTHER. Alpha inside
   the canvas is relative — full at the leading rune, nothing at the end of
   the trail — and everything absolute about how loud the field is lives in
   the opacity below. If the field ever competes with the panel text, this
   is the number that moves. Per CLAUDE.md's standing rule it is NOT fixed
   by toning the palette down or by lightening text: the field is a new
   element and it pays for itself.

   NOT SWITCHED OFF UNDER prefers-reduced-motion, and that is deliberate.
   The field is drawn as a still at exactly this dimness instead, so the
   texture survives and nothing moves — see js/runes.js. Do not add it to
   the reduced-motion block at the foot of this file.

   THE FOOTER'S PAUSE CONTROL LEAVES IT ON SCREEN TOO, for the same reason
   and by the same route. The field registers a stop/start pair with
   js/motion.js; stopping cancels its rAF and leaves the canvas exactly as
   it stood. Nothing here changes when it is paused — there is no paused
   rule to write, because a pause is the absence of the next frame rather
   than a state the stylesheet has to draw.

   IT RUNS ON EVERY PAGE, not just the profile. Anything with this
   stylesheet and the script gets the field on identical terms.
   ------------------------------------------------------------------------ */

.runefield {
  position: fixed;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  opacity: .17;
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
}

/* --- Cel fills -----------------------------------------------------------
   Flat colour inside a shape, hard line around it. No gradient survives on
   a panel; the only gradient left on the page is the sky, which is
   airbrushed on purpose. */

.panel__body { background: var(--paper); }
.friendcount { border-bottom: 2px solid var(--plate); }

/* Photograph frames read as sprite cells: hard ink edge, pixel-stepped. */
.shot,
.album__grid a,
.tile__cover { border-color: var(--plate); }
.tile__cover { border-width: 2px; }

/* --- Period widgets, in the new grammar ---------------------------------- */

.nowplaying__btn,
.textsize button,
.motion button {
  box-shadow:
    inset 2px 2px 0 var(--shine-hi),
    inset -2px -2px 0 color-mix(in srgb, var(--plate) 30%, transparent);
}


/* ==========================================================================
   THE CHARACTER PLAYER

   An attract sequence in the rail: walk in, hold, walk off.
   Early-Mortal-Kombat idiom — few frames, poses held, hard cuts. Nothing here
   eases or tweens; the stepping IS the look.

   NO TEXT ON SCREEN. The panel is picture only. It is decorative: one
   labelled image to a screen reader, holding nothing focusable.
   ========================================================================== */

/* The one item in the left column allowed to grow. It lives in .cabinet,
   which the desktop grid puts under the rail and stretches to the bottom of
   main's second row — so its bottom edge is the main column's bottom edge,
   which is the bottom of the Top 6 panel. Alignment by layout, not by a
   magic number. */
.character {
  position: relative;
  padding: 0;
  overflow: hidden;
  min-width: 0;
  min-height: 180px;
}
/* Only under html.js, where .profile stretches, does this grow to meet the
   Top 6. */
.js .character { flex: 1 1 auto; }

/* With scripting off the panel is never filled, and an empty bordered box in
   the rail says nothing to anyone. It is decoration, so it goes. */
html:not(.js) .character { display: none; }

/* Absolutely positioned so it fills whatever height the panel is given —
   height:100% against an auto-height parent resolves to nothing, which is
   why the panel first rendered empty. */
/* THE FLOOR IS THE PRIMITIVE. --floor is where the soles meet the stage;
   it is a property of the panel, the same for every character, and the floor
   line is drawn at exactly it. The sprite is then positioned so that ITS
   baseline row lands there.

   It used to be the other way round - the sprite sat at a fixed --foot and
   the floor line was computed to meet it - which worked only while every
   character rendered at the same size. It meant the manifest's `scale` could
   not be honoured without the floor line moving with the character, and the
   floor belongs to the stage, not to whoever is standing on it.

   --figh is one character-unit of frame height, multiplied by the
   character's own `scale`.

   Both live on .character rather than on .char__stage because the holding
   state draws the same floor line, and the stage does not exist until
   scripting has run. */
.character {
  --floor: 10.625%;
  --figh: 74%;
}

/* The cabinet screen. One declaration for both the stage and the holding
   state that precedes it, so the handover between them cannot flash: what
   is behind the coin is pixel-for-pixel what is behind the character. */
.char__stage,
.char__wait {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background:
    linear-gradient(180deg,
      color-mix(in srgb, var(--plate) 92%, transparent) 0%,
      color-mix(in srgb, var(--chrome) 62%, transparent) 62%,
      color-mix(in srgb, var(--plate) 92%, transparent) 100%),
    var(--chrome);
}

/* The stage is an isolated stacking context, and that is load-bearing: the
   impact inversion is a `difference` blend, and without isolation it would
   blend through to the page behind the panel. It is also where the zoom
   punch is applied — see THE EFFECTS LAYER below. */
.char__stage {
  isolation: isolate;
  transform-origin: var(--fx-x, 50%) var(--fx-y, 52%);
}

/* THE CAMERA. Everything the screen shake moves, floor line included. It is
   transparent and exactly fills the stage, so translating it never opens a
   gap at the panel edge — the stage's own background is still behind it. */
.char__cam {
  position: absolute;
  inset: 0;
}

/* A floor line, so the character is standing on something. It sits at
   --floor and does not move for anybody — except with the camera, because
   a floor that stays put while the figure shakes is a wobbling sprite
   rather than a screen shake.

   It is drawn on the camera and on the holding state, which are the same
   box as the stage, so the line lands on the same row in both and the
   handover between them cannot move it. The camera's copy runs 12px wide of
   the panel at each end so that a shake cannot pull its ends into view; the
   line is uniform, so within the panel the two are identical. */
.char__cam::after,
.char__wait::after {
  content: '';
  position: absolute;
  left: 0; right: 0;
  bottom: var(--floor);
  height: 2px;
  background: color-mix(in srgb, var(--accent-2) 55%, transparent);
  box-shadow: 0 6px 18px color-mix(in srgb, var(--accent-2) 30%, transparent);
  pointer-events: none;
}
.char__cam::after { left: -12px; right: -12px; }

/* The figure. --x is stepped by the script; there is no transition on it on
   purpose, so each footfall is a cut rather than a glide. */
.char__sprite,
.char__ghost {
  position: absolute;
  left: 50%;
  /* Sized by the character's own `scale`, then dropped so that the frame's
     own baseline row - the one the artwork's soles sit on - lands on
     --floor. --baseline and --ch come straight off the manifest, so a
     character whose feet sit at a different row of a different-sized canvas
     still stands on the line, at whatever size they are drawn. */
  height: calc(var(--figh) * var(--scale, 1));
  bottom: calc(var(--floor)
               - var(--figh) * var(--scale, 1)
                 * (1 - var(--baseline) / var(--ch)));
  width: auto;
  /* --flip is set per beat by the player. The walk-in and the hold sit at
     1 — the frames as drawn, travelling whichever way that character's
     artwork faces — and the walk-off is the same cycle at -1, travelling
     back. The mirror is applied after the translate, so it flips the
     artwork in place and does not move the figure or disturb the ghost
     offsets below. */
  transform: translateX(calc(-50% + var(--x, 0%))) scaleX(var(--flip, 1));
  image-rendering: auto;          /* painted art, not pixel art */
  pointer-events: none;
}

/* ==========================================================================
   THE EFFECTS LAYER

   Pokemon-attack reading: the sprite barely moves and the effect carries
   the spectacle. Ten effects, all drawn in code — a ring is a border, a
   shockwave is a radial gradient, a particle is a square, speed lines are a
   conic gradient. No images, no dependencies.

   THEY COMPOSE, and that is the point: several firing on one beat is the
   normal case. So no two effects need the same property on the same
   element —

     shake       translate on .char__cam
     punch       scale on .char__stage
     flash       .char__flash overlay
     invert      .char__invert overlay
     speedlines  .char__fx
     trail       the two .char__ghost layers
     freeze      animation-play-state on everything under the stage
     ring
     shock       nodes that add themselves and remove themselves
     particles

   NOTHING LEAKS OUT OF THE PANEL. The camera is transparent and slides
   inside the stage; the stage only ever scales UP, into .character's own
   `overflow: hidden`; the overlays are `inset: 0`; every node is a child of
   the camera. "Screen shake" means the panel shakes. The page does not
   move, and no transform here affects layout.

   SIZES ARE IN CANVAS PIXELS. --u is px per canvas pixel, measured off the
   rendered sprite by the player, so a radius of 150 in the manifest means
   150 pixels of the 256 x 320 drawing whatever size the panel is. --fx-x
   and --fx-y are the impact point as a percentage of the camera.
   ========================================================================== */

/* --- afterimage ----------------------------------------------------------
   Two layers, progressively fainter and further back. --trail-dir is the
   way the artwork faces, so the smear always sits behind the figure rather
   than in front of it.
   ------------------------------------------------------------------------ */
.char__ghost { opacity: 0; }
.char__stage[data-trail='true'] .char__ghost--1 {
  opacity: .38;
  transform: translateX(calc(-50% + var(--x, 0%) + var(--trail-dir, 1) * -9px))
             scaleX(var(--flip, 1));
  filter: saturate(.4) brightness(1.3);
}
.char__stage[data-trail='true'] .char__ghost--2 {
  opacity: .2;
  transform: translateX(calc(-50% + var(--x, 0%) + var(--trail-dir, 1) * -18px))
             scaleX(var(--flip, 1));
  filter: saturate(.25) brightness(1.5);
}

/* --- radial speed lines, and the energy bloom under them -----------------
   This layer sat here unwired from the day the faked moves were deleted,
   kept "for when real move sheets land". It lands now: the lines are struck
   out FROM the impact point rather than raked across the panel at a fixed
   angle, and the bloom sits at the same point.

   Masked to a ring around the impact so the lines do not read as a
   screen-wide hatch laid over the art.
   ------------------------------------------------------------------------ */
.char__fx {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  background:
    repeating-conic-gradient(from 0deg at var(--fx-x, 50%) var(--fx-y, 52%),
      color-mix(in srgb, var(--shine-hi) 55%, transparent) 0deg 1.4deg,
      transparent 1.4deg 8deg),
    radial-gradient(circle at var(--fx-x, 50%) var(--fx-y, 52%),
      var(--shine-hi) 0%,
      color-mix(in srgb, var(--accent-2) 70%, transparent) 13%,
      transparent 50%);
  mask-image: radial-gradient(circle at var(--fx-x, 50%) var(--fx-y, 52%),
    transparent 0 7%, #000 24%, #000 40%, transparent 74%);
}
.char__stage[data-lines='true'] .char__fx {
  animation: char-lines var(--lines-ms, 260ms) steps(3, end) 1;
}
@keyframes char-lines {
  0%   { opacity: .85; scale: .88; }
  100% { opacity: 0;   scale: 1.3; }
}

/* --- full-frame flash, and the impact inversion --------------------------
   Both are overlays on the STAGE, not on the camera, so they cover the
   panel corner to corner even while the camera is thrown off centre.

   SEIZURE SAFETY. These are the two dangerous ones and the player rate
   limits them together: at most one every 500ms, i.e. two a second against
   WCAG's ceiling of three. Each is one hard on-off — `steps(1, end)` — so a
   flash is a single luminance swing and not a strobe, and both are short:
   the flash tops out at 160ms and the inversion at 100ms. Do not raise
   those ceilings without reading tools/check-flash-rate.mjs first.
   ------------------------------------------------------------------------ */
.char__flash,
.char__invert {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  background: var(--shine-hi);
}
/* White over the picture in `difference` is an inversion. It needs the
   stage's `isolation: isolate` above or it would reach the page. */
.char__invert { mix-blend-mode: difference; }

.char__stage[data-flash='true'] .char__flash {
  animation: char-flash var(--flash-ms, 90ms) steps(1, end) 1;
}
@keyframes char-flash {
  0%   { opacity: var(--flash-k, .75); }
  55%  { opacity: calc(var(--flash-k, .75) * .35); }
  100% { opacity: 0; }
}
.char__stage[data-invert='true'] .char__invert {
  animation: char-invert var(--invert-ms, 60ms) steps(1, end) 1;
}
@keyframes char-invert {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

/* --- screen shake, with a decaying amplitude -----------------------------
   The decay is written into the keyframes rather than computed: each stop
   is a fraction of --amp, alternating sides and falling away to nothing.
   `steps(1, end)` holds each stop and cuts to the next, which is the hard
   cabinet jitter rather than a smooth wobble.

   --amp is clamped to 5px by the player. That is the most the camera can
   travel without the ends of the floor line coming into view, and the
   line is drawn 12px wide of the panel on top of that.
   ------------------------------------------------------------------------ */
.char__stage[data-shake='true'] .char__cam {
  animation: char-shake var(--shake-ms, 420ms) steps(1, end) 1;
}
@keyframes char-shake {
  0%   { translate: calc(var(--amp) *  1px)   calc(var(--amp) * -.55px); }
  11%  { translate: calc(var(--amp) * -.82px) calc(var(--amp) *  .48px); }
  22%  { translate: calc(var(--amp) *  .66px) calc(var(--amp) *  .40px); }
  33%  { translate: calc(var(--amp) * -.50px) calc(var(--amp) * -.33px); }
  44%  { translate: calc(var(--amp) *  .38px) calc(var(--amp) *  .26px); }
  55%  { translate: calc(var(--amp) * -.28px) calc(var(--amp) * -.20px); }
  66%  { translate: calc(var(--amp) *  .20px) calc(var(--amp) *  .14px); }
  77%  { translate: calc(var(--amp) * -.13px) calc(var(--amp) * -.09px); }
  88%  { translate: calc(var(--amp) *  .07px) calc(var(--amp) *  .05px); }
  100% { translate: 0 0; }
}

/* --- zoom punch ----------------------------------------------------------
   On the stage, from the impact point. It only ever scales UP — the player
   clamps it to 1.01–1.2 — so it can do nothing but overflow into the
   panel's own clip. A scale below 1 would show the panel through the gap,
   which is why the floor of the clamp is above 1 and not at it.
   ------------------------------------------------------------------------ */
.char__stage[data-punch='true'] {
  animation: char-punch var(--punch-ms, 240ms) steps(4, end) 1;
}
@keyframes char-punch {
  0%   { scale: 1; }
  12%  { scale: var(--punch, 1.1); }
  100% { scale: 1; }
}

/* --- rings, shockwaves and particles -------------------------------------
   All three are nodes the player adds to the camera and the browser throws
   away on animationend. They centre on the impact point; their sizes are
   canvas pixels scaled by --u.
   ------------------------------------------------------------------------ */
.char__ring,
.char__shock,
.char__bit {
  position: absolute;
  left: var(--fx-x, 50%);
  top: var(--fx-y, 52%);
  translate: -50% -50%;        /* centring, kept off `transform` so the
                                  animations below can own that outright */
  pointer-events: none;
}

.char__ring,
.char__shock {
  width:  calc(var(--r1) * var(--u, 1) * 1px);
  height: calc(var(--r1) * var(--u, 1) * 1px);
  border-radius: 50%;
}
.char__ring {
  border: calc(var(--rw) * var(--u, 1) * 1px) solid var(--accent-2);
  animation: char-ring var(--ms, 420ms) steps(6, end) 1 both;
}
.char__shock {
  background: radial-gradient(circle,
    transparent 0 48%,
    color-mix(in srgb, var(--shine-hi) 85%, transparent) 54% 62%,
    color-mix(in srgb, var(--accent) 65%, transparent) 64% 78%,
    transparent 84%);
  animation: char-ring var(--ms, 380ms) steps(5, end) 1 both;
}
/* Expansion is a scale so that a hard 3px border stays a hard 3px border
   all the way out — animating width and height would thin it as it grew. */
@keyframes char-ring {
  0%   { transform: scale(calc(var(--r0) / var(--r1))); opacity: .95; }
  100% { transform: scale(1); opacity: 0; }
}

/* Square, unrounded, hard-edged: the pixel idiom the rest of the page is
   drawn in. The ring around each one is a hard box-shadow, never a blur. */
.char__bit {
  width:  calc(var(--sz) * var(--u, 1) * 1px);
  height: calc(var(--sz) * var(--u, 1) * 1px);
  background: var(--accent);
  box-shadow: 0 0 0 calc(var(--u, 1) * 1px)
              color-mix(in srgb, var(--shine-hi) 70%, transparent);
  animation: char-bit var(--ms, 480ms) steps(5, end) var(--delay, 0ms) 1 both;
}
@keyframes char-bit {
  0%   { transform: translate(0, 0); opacity: 1; }
  100% { transform: translate(calc(var(--dx) * var(--u, 1) * 1px),
                             calc(var(--dy) * var(--u, 1) * 1px));
         opacity: 0; }
}

/* --- freeze frame, and the same mechanism for a paused panel -------------
   Hit-stop: everything under the stage holds exactly where it is, so the
   ring stops mid-expansion and the shake stops mid-throw. The frame on
   screen does not change inside a timeline step anyway, so the whole
   picture freezes.

   A panel that has gone off screen gets the identical treatment — it is
   the same requirement, arrived at from the other direction.
   ------------------------------------------------------------------------ */
.char__stage[data-freeze='true'],
.char__stage[data-freeze='true'] *,
.character[data-paused='true'] .char__stage,
.character[data-paused='true'] .char__stage * {
  animation-play-state: paused;
}

/* --- the preview bar -----------------------------------------------------
   ONLY under ?preview=moves. It is here so that nobody can look at the panel
   and mistake a preview of the effects for finished character art.

   NO MOVE IS MARKED `standIn` AS OF 19 SEP 2026 - Kai's gate was turned off
   on Kearnan's instruction - so the bar is currently built by nothing. The
   rule and its styling are kept for the next move whose frames are stand-ins.

   It borrows the INSERT COIN marquee's treatment exactly — accent on plate,
   a measured pair — so it reads as cabinet furniture and adds no colour
   pair to tools/check-contrast.mjs.

   It sits on the PANEL rather than on the stage, because the zoom punch
   scales the stage and a warning label that slides off screen when the move
   lands is not a warning label.
   ------------------------------------------------------------------------ */
.char__preview {
  position: absolute;
  z-index: 3;
  top: 0; left: 0; right: 0;
  padding: 5px 0;
  background: var(--plate);
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
  color: var(--accent);
  font-family: var(--font-ui);
  font-size: var(--fs-data);
  letter-spacing: .14em;
  text-align: center;
  text-indent: .14em;
  pointer-events: none;
}

/* --- The name plate: REMOVED, AND THIS IS THE THIRD STATE ----------------
   There is no `.char__name` rule here and there is no `.char__name` node.
   The chain, so the removal does not read as an earlier mistake coming
   back: built, then DELETED and settled; then REINSTATED on 19 Sep 2026
   when Kearnan asked for a 1.5s entrance with the plate inside it; then
   REMOVED AGAIN the same day at his instruction, so a character walks in
   and goes straight into its routine.

   Consequences worth knowing before anybody puts it back:

     * `Archivo Narrow` is back to THREE places on the site — the profile
       name, the section header bars and the album titles. It is not on the
       panel.
     * The panel carries no text in its normal run. The only node that ever
       writes text onto it is .char__preview above, which needs
       ?preview=moves and is unreachable from the site.
     * It added no colour pair to tools/check-contrast.mjs when it was here
       and it takes none away now: it borrowed the --plate / --plate-ink
       pair the name bar already owns.
   ------------------------------------------------------------------------ */

/* ==========================================================================
   THE HOLDING STATE, AND WHAT HAPPENED TO IT WHEN THE COIN LEFT

   The panel is in the markup from the start, but its character cannot arrive
   until after first paint - the photographs come first, deliberately, and on
   a slow connection that is several seconds. This is what sits there instead
   of an empty box: the same cabinet screen, drawn from the same declaration
   as the stage so the handover between them cannot flash.

   IT USED TO BE THE COIN AND IT IS NOT ANY MORE. INSERT COIN became a
   permanent banner across the top of the panel on 19 Sep 2026, and a bar
   that is always there can say nothing about whether the frames arrived. So
   the cabinet keeps a word of its own, on the same plate-and-accent bar the
   coin used to be, and it says which of the two things is happening:

     PLEASE WAIT     in the markup, so it is up at first paint
     OUT OF ORDER    written by js/character.js once the player has
                     established that no character can be shown

   The alternative - leaving the failure case as an empty cabinet with a
   banner over it - would have been worse than what was there before, so it
   was not done. The screen is removed the moment the player has a character
   whose frames actually arrived; if none ever do it never sets data-ready,
   and the cabinet stays up with OUT OF ORDER on it.

   aria-hidden, nothing focusable. It is decoration, not a status message,
   and the one thing on this panel that IS in the accessibility tree is the
   banner below, which is a control.
   ========================================================================== */

.char__wait {
  z-index: 2;                 /* the stage is assembled underneath it */
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.character[data-ready='true'] .char__wait { display: none; }

/* The cabinet's word. Full width, so it reads as a bar across the screen
   rather than a floating label - which is what the coin was doing here
   before it moved to the top. It does not blink: one blinking thing on this
   panel is the banner, and a second would be noise. */
.char__word {
  display: block;
  width: 100%;
  padding: 7px 0;
  background: var(--plate);
  border-top: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
  color: var(--accent);
  font-size: 11px;
  letter-spacing: .22em;
  text-align: center;
  text-indent: .22em;         /* the tracking would otherwise push it left */
}

/* ==========================================================================
   THE INSERT COIN BANNER, AND IT IS A CONTROL

   A marquee across the top of the panel, full width, permanent. The
   characters walk behind it and the tall ones are clipped at the crown:
   MEASURED, and intended. On the 300 x 215 panel the bar is 34px, and the
   crowns land at Ragn 31px, Kadu 34px, Kai/Zen/Syn/Umlilo/Miko 49px,
   Freq 52px, Orun 57px - so Ragn loses 5px of hood and Kadu 2px, standing
   still. Under the zoom punch, which scales the stage to at most 1.2 about
   the impact point, eight of the nine reach the band and only Orun never
   does. Do not shrink the bar, the panel or anybody's `scale` to stop that
   happening.

   LEGIBILITY IS BOUGHT WITH AN OPAQUE FILL AND NOTHING ELSE, and that is
   the whole of the answer to "legible against a character passing behind
   it". The bar is a flat, fully opaque --plate, so no pixel of any frame,
   of any character, ever reaches the text.

   The worst case was measured rather than eyeballed: all nine characters,
   all 71 walk frames, each at rest, under a 5px shake, at the 1.2 punch
   ceiling and at both together, sampling every pixel that lands in the
   band. Relative luminance runs from 0.00015 - a near-black on Kadu's
   walk_03 - to 0.98872, a near-white on Umlilo's walk_04. Against
   --accent at 0.5994 those are 12.95:1 and 1.60:1. An inversion swaps
   them, so the same two frames give 1.61:1 and 12.89:1. In other words a
   translucent bar would be unreadable over half the cast whichever way the
   tone went, and there is no text colour that fixes it, because the
   backdrop covers the whole range. An opaque fill is the only treatment
   that holds. DO NOT MAKE THIS TRANSLUCENT.

   On the plate the pair is --accent on --plate at 10.74:1, which
   tools/check-contrast.mjs already measures. No pair is added.

   THE EFFECTS LAYER CANNOT REACH IT EITHER, and that is structural rather
   than lucky. The flash and the inversion are overlays on .char__stage and
   the rings, shockwaves and particles are children of .char__cam inside it;
   the stage is an isolated stacking context, and this bar is its SIBLING,
   painted above it. So `difference` has nothing of the bar to invert and
   the flash has nothing of it to wash out. It is also outside the stage's
   transform, so the zoom punch does not scale it and the shake does not
   move it - the same reasoning the preview bar is built on.

   It is a <button>: focusable, keyboard operable, named for what it does.
   See js/coin.js for the window it opens.
   ========================================================================== */

.char__banner {
  position: absolute;
  z-index: 4;                 /* over the stage AND over the cabinet screen */
  top: 0; left: 0; right: 0;
  display: block;
  width: 100%;
  margin: 0;
  /* 10px, and the number is measured rather than picked. It puts the bar
     at 36px on the 215px panel, which is what makes Ragn (crown at 31px)
     and Kadu (34px) actually pass behind it while the other seven clear
     it — "slightly cut off at the top of the frame", which is intended.
     The panel and the figures were NOT resized to arrange that. */
  padding: 10px 0;
  background: var(--plate);
  border: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
  /* A hard offset, never blurred, so the bar reads as sitting ON the
     picture rather than floating over it. */
  box-shadow: 0 2px 0 color-mix(in srgb, var(--plate) 70%, transparent);
  font-family: var(--font-ui);
  cursor: pointer;
  /* It is a link since 8 Oct 2026, not a button. A link inherits the body's
     line-height where a button does not, which made the bar 37px rather
     than 34px; `normal` puts back the button's line box, so the height the
     crowns are measured against is unchanged. No underline on a bar. */
  line-height: normal;
  text-decoration: none;
}

/* The panel clips to its own box, so an outline offset outwards would be
   cut off. The ring goes inside instead and is fully visible. */
.char__banner:focus-visible { outline-offset: -2px; }

/* The blinking label. The BAR does not blink - only the word does - because
   a bar that goes transparent every 550ms would let the top of a character's
   head pop through it, which reads as a fault rather than as a marquee. */
.char__coin {
  display: block;
  color: var(--accent);
  font-size: 11px;
  letter-spacing: .22em;
  text-align: center;
  text-indent: .22em;         /* the tracking would otherwise push it left */
  animation: coin-blink 1.1s steps(1, end) infinite;
}

/* A hard on/off, not a fade — the same refusal to ease that the rest of the
   panel is built on. */
@keyframes coin-blink {
  0%  { opacity: 1; }
  50% { opacity: 0; }
}

/* Reading a control is not optional, so it stops blinking while it is being
   pointed at or focused. Same stopped look as everywhere else. */
.char__banner:hover .char__coin,
.char__banner:focus-visible .char__coin { animation: none; opacity: 1; }

/* ==========================================================================
   Reduced motion: NO animation at all. The last frame of the walk, held —
   the same frame the sequence itself pauses on, not whatever the loop
   happened to stop at.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  /* THE EFFECTS LAYER IS OFF. Not reduced, not shortened — off. The player
     refuses to fire an effect as well, so this is the second of two locks
     rather than the only one, and it is written as `display: none` on
     everything the layer draws so that a node created by some future path
     still cannot appear. */
  .char__fx,
  .char__ghost,
  .char__flash,
  .char__invert,
  .char__ring,
  .char__shock,
  .char__bit { display: none; }
  /* And the camera does not move, whatever is set on it. */
  .char__stage,
  .char__cam {
    animation: none !important;
    translate: none !important;
    scale: none !important;
  }
  /* The marquee stops blinking and simply stays lit. */
  .char__coin { animation: none; opacity: 1; }
  /* There was a second lock on the name plate here — `display: none` on top
     of a sequence that never runs, the same doubling the effects layer
     gets. The plate was removed on 19 Sep and the rule went with it, so
     there is nothing to lock. Reduced motion is otherwise untouched: one
     still, no effects, no sequence. */
  /* The play button stops blinking but still reads as busy. */
  .player[data-state='waking'] [data-play] { animation: none; opacity: .55; }
}

@media (max-width: 1023px) {
  /* The page is one column here and this panel is last in it, so there is
     no column to align with. */
  .character { flex: 0 0 auto; height: 260px; }
}
