/* ==========================================================================
   5uperNoa OS — shared theme
   Used by every page. Keep page-specific styling out of this file.

   Theme is a single display axis, persisted via localStorage and toggled
   from the .os-controls button (injected by base.js, lives outside .card,
   bottom-center of the viewport): mono (1-bit) <-> arcade (8-bit) [body.arcade-mode].
   Default is arcade (8-bit).
   ========================================================================== */

:root{
  --black:#000000;
  --white:#ffffff;
  --gray-1:#141414;
  --gray-2:#2b2b2b;
  --gray-3:#6b6b6b;
  --gray-4:#a8a8a8;
  --gray-5:#dcdcdc;

  --font-pixel:'Press Start 2P','Courier New',monospace;
  --font-mono:'JetBrains Mono','SF Mono','Consolas',monospace;
  --font-body:'Helvetica Neue',Helvetica,Arial,sans-serif;

  /* mono / dark (fallback + explicit 1-bit dark) */
  --bg:#000000;
  --fg:#ffffff;
  --card-bg:#000000;
  --card-border:#ffffff;
  --muted:#8c8c8c;
  --grid-line:rgba(255,255,255,0.045);
  --scan-opacity:0.5;
  --header-bg:#ffffff;
  --header-fg:#000000;
  --star:#ffffff;
}

*, *::before, *::after{
  box-sizing:border-box;
  border-radius:0;
}

/* Everything tappable opts out of the browser's double-tap-to-zoom wait.
   Without this, mobile browsers hold each tap for ~300ms to see whether a
   second one is coming, which reads as "taps don't register / need a second
   tap". Pair with the (hover:hover) guards on every :hover rule further
   down — both are needed; either one alone still feels broken on touch. */
a, button, .tile, .gallery-card, [role="button"], [tabindex]{
  touch-action:manipulation;
}

html,body{ height:100%; }

/* ---------------------------------------------------------------------------
   Interface scale.

   The site was designed at a browser zoom of 100% but reads better at 90% —
   denser, more of a page visible inside the window — so 90% is now what 100%
   gives you. It's the root font-size rather than `zoom` or a transform on
   purpose: everything sized in rem (the whole type and spacing scale) shrinks,
   while the things that SHOULDN'T shrink don't —

     • the window itself, at 78vw × 78vh, keeps its size on screen
     • hero type on clamp(…vw…) keeps its size, exactly as under real zoom
     • hairline borders stay hairlines instead of blurring to .9px

   Desktop only. Phones never had the zoom applied to them in the first place,
   and the mobile shell's type is already sized for a small screen — shaving
   a tenth off it there would just be small text.
   --------------------------------------------------------------------------- */
@media (min-width:769px){
  html{ font-size:90%; }
}

body{
  margin:0;
  background-color:var(--bg);
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size:26px 26px;
  color:var(--fg);
  font-family:var(--font-body);
  /* "regular" Helvetica Neue reads too narrow for this — widen it. There's
     no freely-hostable "Helvetica Neue Extended" face, so this approximates
     it: font-stretch requests the OS's wide variant where one exists, and
     letter-spacing does the rest everywhere else. Inherited, so it reaches
     .description and anything else on --font-body without repeating it. */
  font-stretch:expanded;
  letter-spacing:.015em;
  -webkit-font-smoothing:antialiased;
  overflow-x:hidden;
  transition:background-color .35s ease, color .35s ease;
}

a{ color:inherit; }

/* ---------- 8-bit arcade display mode (default) ---------- */
body.arcade-mode{
  --bg:#07000f;
  --fg:#f2f2f5;
  --card-bg:rgba(6,0,20,0.94);
  --card-border:#39ff14;
  --muted:#9a8dcf;
  --grid-line:rgba(57,255,20,0.05);
  --scan-opacity:0.35;
  --header-bg:#150029;
  --header-fg:#39ff14;
  --accent-a:#ff2bd6;
  --accent-b:#00e5ff;
  --accent-c:#ffe600;
  --star:#ffe600;
  background-image:
    radial-gradient(rgba(255,255,255,0.55) 1px, transparent 1.4px),
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size:90px 90px, 26px 26px, 26px 26px;
}
body.arcade-mode .loader-bar{ color:var(--card-border); }
body.arcade-mode .cursor{ color:var(--accent-a); }
body.arcade-mode .meta-key{ color:var(--accent-b); }
body.arcade-mode .divider{ border-top-color:var(--accent-a); opacity:.5; }
body.arcade-mode .os-btn{ border-color:var(--card-border); color:var(--card-border); }
body.arcade-mode .name{ animation:flicker-arcade 6.5s infinite; }
body.arcade-mode .tile:focus-visible{ border-color:var(--accent-b); }
body.arcade-mode .btn-download{ border-color:var(--accent-c); color:var(--accent-c); }
/* :hover rules below are gated behind (hover:hover) so touch devices never
   get the classic iOS "first tap triggers :hover, second tap actually
   clicks" behavior — every hover-only style in this file follows this. */
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  body.arcade-mode .os-btn:hover{ background:var(--card-border); color:var(--bg); }
  body.arcade-mode .tile:hover{ border-color:var(--accent-b); }
  body.arcade-mode .btn-download:hover{ background:var(--accent-c); color:var(--bg); }
}

@keyframes flicker-arcade{
  0%,89%,91%,93%,95%,100%{ opacity:1; transform:translate(0,0); text-shadow:none; }
  90%{ opacity:.9; transform:translate(-2px,0); text-shadow:2px 0 var(--accent-a), -2px 0 var(--accent-b); }
  92%{ opacity:1;  transform:translate(2px,0);  text-shadow:-2px 0 var(--accent-b), 2px 0 var(--accent-c); }
  94%{ opacity:.94; transform:translate(0,-1px); text-shadow:1px 0 var(--accent-c), -1px 0 var(--accent-a); }
}

/* ---------- fixed atmosphere layers ---------- */
.scanlines{
  position:fixed; inset:0;
  background:repeating-linear-gradient(
    to bottom,
    rgba(255,255,255,0.045) 0px,
    rgba(255,255,255,0.045) 1px,
    transparent 1px,
    transparent 3px
  );
  opacity:var(--scan-opacity);
  mix-blend-mode:overlay;
  pointer-events:none;
  z-index:1;
  animation:scanroll 9s linear infinite;
}
.vignette{
  position:fixed; inset:0;
  background:radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.65) 100%);
  pointer-events:none;
  z-index:1;
}
@keyframes scanroll{ from{ background-position-y:0; } to{ background-position-y:120px; } }

/* ---------- layout ---------- */
.stage{
  position:relative;
  z-index:2;
  min-height:100vh;
  display:flex;
  align-items:center;
  justify-content:center;
  padding:3.5vh 3vw;
}

.card{
  width:78vw;
  max-width:1400px;
  min-width:380px;
  height:78vh;
  min-height:420px;
  max-height:92vh;
  background:var(--card-bg);
  border:2px solid var(--card-border);
  position:relative;
  text-align:left;
  display:flex;
  flex-direction:column;
  overflow:hidden;
  transition:background-color .35s ease, border-color .35s ease;
}
/* Only added while the fullscreen double-click/tap is actively animating —
   see base.js. Left off the base .card rule on purpose: if left/top/width/
   height transitioned all the time, dragging and resizing (which update
   those same properties on every pointer-move) would lag behind the cursor
   instead of tracking it 1:1. */
/* The window's move/resize transition is defined ONCE, in content.css.

   It used to be declared here as well, and content.css — which loads second —
   silently won. Every adjustment made to this copy did nothing at all, which is
   a very expensive kind of bug to chase: the rule looks right, the change looks
   applied, and the page ignores it. One definition, in the file that loads last.
   See `.card.is-animating` in content.css. */

/* ---------- terminal chrome bar: back/forward | breadcrumb | home ---------- */
/* Double-click/double-tap this bar to snap the window full-page, or back
   to its default size — see base.js initFullscreenToggle(). */
.chrome{
  flex:0 0 auto;
  display:flex;
  align-items:center;
  gap:.7rem;
  background:var(--header-bg);
  color:var(--header-fg);
  padding:.55rem .9rem;
  font-family:var(--font-mono);
  font-size:.65rem;
  letter-spacing:.06em;
  text-transform:uppercase;
  border-bottom:2px solid var(--card-border);
}

.chrome-left{ display:flex; align-items:center; gap:.4rem; flex-shrink:0; }

/* home icon sits at the opposite end of the bar from back/forward */
/* The star is the way home and the site's mark; it earns more presence than the
   other controls in this bar, which are all utilities. Bigger and at full
   opacity, so it reads as the anchor of the row rather than as one more icon
   sitting in it. */
.chrome-home-icon{
  display:flex;
  align-items:center;
  justify-content:center;
  width:26px;
  height:26px;
  flex-shrink:0;
  color:inherit;
  opacity:1;
  transition:transform .18s ease;
}
.chrome-home-icon .pixel-icon{ width:21px; height:21px; }
.chrome-home-icon:focus-visible{ outline:1px dashed currentColor; outline-offset:2px; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .chrome-home-icon:hover{ transform:scale(1.12); }
}

.crumb{
  flex:1 1 auto;
  min-width:0;
  display:flex;
  align-items:center;
  overflow:hidden;
  white-space:nowrap;
}
.crumb a{
  color:inherit;
  text-decoration:none;
  opacity:.72;
}
.crumb a:focus-visible{ outline:1px dashed currentColor; outline-offset:2px; }
.crumb-current{ opacity:1; }
.crumb-sep{ opacity:.45; padding:0 .35em; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .crumb a:hover{ opacity:1; text-decoration:underline; }
}

.nav-btn{
  background:none;
  border:none;
  color:inherit;
  font-family:inherit;
  cursor:pointer;
  font-size:1.05rem;
  font-weight:700;
  line-height:1;
  opacity:.9;
  /* A fixed square rather than padding around the glyph. ◀ and ▶ are not
     guaranteed the same advance width in every font — in some fallbacks one is
     visibly wider than the other — and a shared box makes the pair identical
     whatever face they land in. */
  display:inline-flex;
  align-items:center;
  justify-content:center;
  width:1.5em;
  height:1.5em;
  padding:0;
}
.nav-btn:focus-visible{ outline:1px dashed currentColor; outline-offset:2px; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .nav-btn:hover{ opacity:1; }
}

@media (max-width:480px){ .nav-btn{ font-size:.9rem; } }

/* ---------- body content ---------- */
.card-body{
  flex:1 1 auto;
  overflow-y:auto;
  overflow-x:hidden;
  padding:clamp(1.5rem,4vw,3rem);
  /* the window itself is user-resizable, so components inside it (the home
     card grid, in particular) respond to ITS width via @container queries
     below, not the viewport — a plain @media query would stay in "desktop"
     layout even after someone shrinks the window down narrow. */
  container-type:inline-size;
}

/* Hidden the instant this stylesheet loads — before any script has had a
   chance to run at all — until scroll_hint_inline_jump() (app/content.php)
   positions it and reveals it again. A synchronous inline script is a
   strong bet against a flash of the wrong scroll position, but not a
   guarantee: some browsers occasionally paint already-parsed content before
   a later inline script runs, which is exactly what an intermittent
   (browser-dependent, not-every-load) flash looks like. CSS applying from
   the stylesheet is not a race against script timing the way that inline
   script is. visibility, not display: layout and scrollHeight both stay
   measurable while hidden, so nothing reflows on reveal. */
body[data-scroll-hint]:not(.scroll-hint-ready) .card-body,
body[data-scroll-hint].page-mode:not(.scroll-hint-ready){
  visibility:hidden;
}

.eyebrow{
  font-family:var(--font-mono);
  font-size:.7rem;
  letter-spacing:.08em;
  text-transform:uppercase;
  color:var(--muted);
  margin:0 0 1.25rem 0;
}

.name{
  font-family:var(--font-pixel);
  font-weight:400;
  font-size:clamp(1.5rem, 8vw, 4rem);
  line-height:1.15;
  letter-spacing:.01em;
  margin:0 .9rem 1.75rem 0;
  color:var(--fg);
  white-space:nowrap;
  overflow-wrap:normal;
  word-break:keep-all;
  animation:flicker 6.5s infinite;
}
.heading{
  font-family:var(--font-pixel);
  font-weight:400;
  font-size:clamp(1.1rem, 4vw, 1.85rem);
  line-height:1.4;
  margin:0 0 1.25rem 0;
  color:var(--fg);
}

/* logo mark paired with the "5uperNoa" hero heading on the home page.
   Both are sized off the SAME font-size (set once, here, on the row) so the
   logo:text ratio stays exact at every viewport width — two independent
   clamp() curves drift apart as the window resizes, which is what made the
   logo read as inconsistently smaller than the text before. */
/* The hero and the About button, one row. align-items:center puts the button
   on the name's centre line rather than the block's, which is what makes it
   look deliberately paired with "5uperNoa" instead of parked in a corner. */
/* ONE font-size, set here on the row, and everything in it is sized in em off
   that: the star at 1.05em, the name at 1em, the About button at .82em. That's
   what guarantees the button can never outgrow the logo — the relationship is
   fixed by the units, not tuned per breakpoint and hoped for. It's the same
   technique that keeps the logo:text ratio exact (two independent clamp()
   curves drift apart as the window resizes). */
.hero-line{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:1rem;
  margin-bottom:1.75rem;
  min-width:0;
  /* No overflow:hidden here, deliberately. .card-body already clips
     horizontally, which is the edge the wordmark should vanish at — and
     clipping on this element as well cut off the About caption, which hangs
     below the button on mobile. Clip at the scroll container, not at every box
     on the way to it. */
  /* --hero-scale multiplies the whole row; everything inside is em off this, so
     one number moves the star, the name and the button together. Set from the
     admin panel — see home_hero_style(). */
  font-size:calc(clamp(1.5rem, 8vw, 4rem) * var(--hero-scale, 1));
}

@media (min-width:769px){
  /* Desktop-only baseline reduction — star and wordmark scale together off
     this one font-size, so shrinking it here shrinks both together, without
     touching --hero-scale (the admin-panel knob) or mobile's own separate
     .88 multiplier below. .8 * .8 = .64 — a second 20% cut stacked on the
     first, not a fresh 20% off the original size. */
  .hero-line{ font-size:calc(clamp(1.5rem, 8vw, 4rem) * var(--hero-scale, 1) * .64); }
}

.hero-row{
  display:flex;
  align-items:center;
  gap:.9rem;
  min-width:0;
  font-size:1em;
  /* Also the About link now (see index.php) — reset here rather than a
     separate selector, since a plain, unlinked div gets these as harmless
     no-ops when there's nowhere for it to go. */
  color:inherit;
  text-decoration:none;
}
.hero-row .name{ margin:0; font-size:1em; }
.hero-logo{
  width:var(--hero-logo, 1.05em);
  aspect-ratio:359/310;
  flex-shrink:0;
  color:var(--fg);
}
.hero-logo .pixel-icon{ width:100%; height:100%; }
/* A raster upload (not an SVG, so not mask-icon eligible — see
   media_is_mask_icon()) won't necessarily be 359:310 like the default star.
   object-fit:contain keeps it from stretching to fill the box instead. */
.hero-logo img{ display:block; width:100%; height:100%; object-fit:contain; }
@keyframes flicker{
  0%,89%,91%,93%,95%,100%{ opacity:1; transform:translate(0,0); text-shadow:none; }
  90%{ opacity:.85; transform:translate(-2px,0); text-shadow:2px 0 var(--fg), -2px 0 var(--bg); }
  92%{ opacity:1;   transform:translate(2px,0);  text-shadow:-2px 0 var(--fg), 2px 0 var(--bg); }
  94%{ opacity:.92;  transform:translate(0,-1px); }
}

.loader{ margin-bottom:1.75rem; }
.loader-label{
  font-family:var(--font-mono);
  font-size:.8rem;
  letter-spacing:.04em;
  text-transform:uppercase;
  margin:0 0 .5rem 0;
  color:var(--fg);
}
.loader-bar{
  font-family:var(--font-mono);
  font-size:clamp(.7rem,2.4vw,.9rem);
  letter-spacing:0;
  margin:0;
  color:var(--muted);
  white-space:pre;
}

.cursor{ animation:blink 1s steps(1) infinite; }
@keyframes blink{ 50%{ opacity:0; } }

.divider{
  border:none;
  border-top:1px dashed var(--muted);
  margin:1.75rem 0;
  opacity:.6;
}

/* The line under a heading that says what the page is.

   Deliberately small and quiet. It was body-size with a 2rem margin, which made
   it a lead paragraph competing with the work below it — the reason it felt
   like it was eating the page.

   Small does NOT mean less valuable to a search engine: font-size carries no
   weight in ranking, and text sitting directly under the <h1> is about the best
   place on a page for a sentence describing it. So this is the arrangement that
   gets both — the page stays about the work, and every page still has a real
   description in its markup rather than only in a meta tag. */
/* The line under a heading that says what the page is.

   Small and quiet — it was body-size with a 2rem margin, which made it a lead
   paragraph competing with the work below it. Small does NOT mean less valuable
   to a search engine: font-size carries no ranking weight, and a sentence
   directly under the <h1> is about the best placement there is. So this gets
   both — the page stays about the work, and every page still has a real
   description in its markup rather than only in a meta tag. */
.description{
  /* The eyebrow's family and size — same kind of thing, a quiet typed line
     saying what you're looking at — but brighter than the eyebrow, because this
     one is a sentence someone is meant to actually read rather than a label
     they skim past. Mixed toward --fg rather than set to it, so it still sits
     below the heading in the hierarchy and stays theme-aware.

     Not uppercased, though the eyebrow is: an eyebrow is two or three words, a
     description is a sentence, and sentences in capitals read markedly slower. */
  /* --font-body, the same role as every other run of prose on the site. That's
     the point: a description IS body text, so swapping the Body font in
     Settings has to move this with it. Tying it to the mono role would have
     left one paragraph that quietly refused to follow the rest.

     Brighter than --muted, because it's a sentence meant to be finished, but
     mixed toward --fg rather than set to it so it still reads as secondary to
     the heading above it. */
  font-family:var(--font-body);
  font-size:clamp(.8rem,1.4vw,.92rem);
  line-height:1.55;
  color:color-mix(in srgb, var(--fg) 82%, var(--muted));
  max-width:58ch;
  margin:-.4rem 0 1.5rem 0;   /* pulled up under the heading it belongs to */
}
.description a{ text-decoration-color:var(--muted); }

.meta-row{
  display:flex;
  flex-wrap:wrap;
  gap:1.5rem 2rem;
  margin-bottom:2rem;
}
.meta-item{
  display:flex;
  flex-direction:column;
  gap:.3rem;
  font-family:var(--font-mono);
}
.meta-key{
  font-size:.62rem;
  letter-spacing:.1em;
  text-transform:uppercase;
  color:var(--muted);
}
.meta-val{
  font-size:.8rem;
  letter-spacing:.02em;
  color:var(--fg);
}

/* ---------- theme control — fixed outside the window, bottom-center of the background ---------- */
.os-controls{
  position:fixed;
  left:50%;
  bottom:1.25rem;
  transform:translateX(-50%);
  z-index:5;
  display:flex;
  justify-content:center;
}
/* the maximized window covers the whole viewport, so the toggle (which sits
   outside the window, on the background) would otherwise float on top of it */
body.is-fullscreen-window .os-controls{ display:none; }
.os-btn{
  display:inline-block;
  background:transparent;
  color:var(--fg);
  border:1px solid var(--fg);
  font-family:var(--font-mono);
  font-size:.62rem;
  letter-spacing:.05em;
  text-transform:uppercase;
  padding:.55em .85em;
  cursor:pointer;
  text-align:left;
  opacity:.82;
}
.os-btn:focus-visible{ outline:2px dashed var(--fg); outline-offset:3px; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .os-btn:hover{ opacity:1; background:var(--fg); color:var(--bg); }
}

@media (max-width:768px){
  .os-controls{
    position:static;
    transform:none;
    /* viewport-fit=cover (set on every page) is what makes the safe-area
       inset actually resolve to something other than 0 on notched phones */
    margin:1.25rem 5.5vw calc(2.5rem + env(safe-area-inset-bottom, 0px));
  }
}

/* ---------- resize handle (desktop only) ---------- */
.resize-handle{
  display:none;
  position:absolute;
  right:0; bottom:0;
  width:18px; height:18px;
  cursor:nwse-resize;
  z-index:7;   /* above the news strip (6), which shares this corner */
}
.resize-handle::before{
  content:'';
  position:absolute;
  inset:4px;
  background-image:repeating-linear-gradient(135deg, var(--muted) 0 2px, transparent 2px 5px);
  opacity:.65;
}
@media (min-width:769px){
  .resize-handle{ display:block; }
  .chrome{ cursor:move; }
}

@media (max-width:768px){
  /* min-width:380px from the desktop rule was never cleared here — on any
     phone narrower than ~420px effective width (stage padding included)
     that forced the card wider than the viewport itself. That's the window
     overflowing the screen edges. */
  .card{ width:100%; height:auto; min-width:0; max-width:none; min-height:0; max-height:none; }
  .card-body{ overflow:visible; }
}

@media (max-width:480px){
  .stage{ padding:6vh 5.5vw; }
  .card-body{ padding:1.5rem; }
  .meta-row{ gap:1rem 1.5rem; }
}

/* ==========================================================================
   star accent — sparing use only (selection cursor, breadcrumb, buttons)
   ========================================================================== */
.star{
  color:var(--star);
  display:inline-block;
}

/* ==========================================================================
   pixel-icon — generic recolorable icon renderer. The element carrying this
   class needs its own mask-image set inline (path depth differs per page),
   e.g. style="-webkit-mask-image:url(../assets/img/thumbs/x.svg);mask-image:url(../assets/img/thumbs/x.svg)"
   and picks up color from the nearest ancestor's `color`.
   ========================================================================== */
.pixel-icon{
  display:block;
  background-color:currentColor;
  -webkit-mask-repeat:no-repeat;
  mask-repeat:no-repeat;
  -webkit-mask-position:center;
  mask-position:center;
  -webkit-mask-size:contain;
  mask-size:contain;
}

/* ==========================================================================
   media / thumbnail placeholders
   ========================================================================== */
.media-placeholder{
  display:flex;
  align-items:center;
  justify-content:center;
  border:1px dashed var(--muted);
  color:var(--muted);
  font-family:var(--font-mono);
  font-size:.65rem;
  letter-spacing:.06em;
  text-transform:uppercase;
  text-align:center;
  padding:2.5rem 1rem;
  margin:0 0 1.1rem 0;
  min-height:150px;
}

.thumb-box{
  flex:0 0 92px;
  width:92px;
  height:92px;
  display:flex;
  align-items:center;
  justify-content:center;
  border:1px solid var(--muted);
  background:var(--grid-line);
  color:var(--muted);
  padding:1.1rem;
  transition:border-color .2s ease, color .2s ease;
}
.thumb-box .pixel-icon{ width:52px; height:52px; }
.tile-h:focus-visible .thumb-box{ border-color:var(--fg); color:var(--card-border); }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .tile-h:hover .thumb-box{ border-color:var(--fg); color:var(--card-border); }
}

/* icon used above the title on the home character-select tiles */
.tile-icon{
  width:40px;
  height:40px;
  margin:0 0 1rem 0;
  color:var(--muted);
  transition:color .2s ease;
}
.tile-icon .pixel-icon{ width:100%; height:100%; }
.tile:focus-visible .tile-icon{ color:var(--card-border); }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .tile:hover .tile-icon{ color:var(--card-border); }
}

/* ==========================================================================
   character-select tiles (home)
   ========================================================================== */
.tile-grid{
  display:grid;
  /* min() so a 220px minimum can never exceed a narrower container and
     force horizontal overflow — see the .gallery-card note on min-width */
  grid-template-columns:repeat(auto-fit, minmax(min(220px, 100%), 1fr));
  gap:1rem;
}
.tile{
  position:relative;
  display:block;
  min-width:0;
  text-decoration:none;
  color:var(--fg);
  border:1px solid var(--muted);
  padding:1.5rem 1.25rem;
  font-family:var(--font-mono);
  cursor:pointer;
  background:transparent;
  text-align:left;
  transition:border-color .2s ease, transform .12s ease;
}
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .tile:hover{
    border-color:var(--fg);
    outline:none;
    transform:translateY(-2px);
  }
}
.tile:focus-visible{
  border-color:var(--fg);
  outline:none;
  transform:translateY(-2px);
}
/* star + title share a flex row so the star's space is always reserved —
   no absolute-position math to keep in sync with whatever sits above it. */
.tile-head{
  display:flex;
  align-items:baseline;
  gap:.5em;
  margin:0 0 .5rem 0;
}
/* Dimmed at rest, full on hover — rather than absent and then appearing.
   The star is the selection cursor, and a cursor that only exists once you've
   already found the thing isn't telling you anything. Sitting there faintly, it
   reads as "these are selectable" before you touch any of them, and hover then
   says which one. Its space was always reserved either way, so nothing shifts. */
.tile-cursor{
  flex-shrink:0;
  opacity:.32;
  transition:opacity .15s ease;
}
.tile:focus-visible .tile-cursor{ opacity:1; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .tile:hover .tile-cursor{ opacity:1; }
}
/* On touch there is no hover to promote it, so the resting state is a little
   stronger — and the press state takes it the rest of the way. */
@media (hover:none){
  .tile-cursor{ opacity:.45; }
  .tile:active .tile-cursor{ opacity:1; transition-duration:.06s; }
}
.tile-title{
  font-size:.85rem;
  letter-spacing:.04em;
  text-transform:uppercase;
  margin:0;
}
.tile-sub{
  font-size:.68rem;
  color:var(--muted);
  letter-spacing:.03em;
}
.tile-tag{
  display:inline-block;
  margin-top:.85rem;
  font-size:.6rem;
  letter-spacing:.08em;
  text-transform:uppercase;
  color:var(--muted);
  border:1px solid var(--muted);
  padding:.25em .55em;
}

/* ---------- home character-select ---------- */
/* Base = phone: a slim horizontal "tape", text left / art right — this one
   stays as-is, it's the version that already works well. Once the window
   (not the viewport — see .card-body's container-type above) is wider than
   480px, it switches to a 3-column card grid, art on top / text on bottom,
   so all three sections are visible at once without crowding. */
/* Phones get two columns of cards. This used to be a stack of full-width
   "tape" bars — text left, art right — which read fine but gave the artwork a
   44px slot and made the featured sections look like a menu rather than a
   choice. Two columns halves the scrolling and gives the art room.

   The card is built the same way here as on desktop: art on top, text beneath,
   centred. Only the column COUNT changes further down, which is why almost all
   of the layout now lives in this base rule rather than being duplicated. */
.select-grid{
  display:grid;
  grid-template-columns:repeat(2, minmax(0, 1fr));
  gap:.85rem;
}
.tile-select{
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:flex-start;
  text-align:center;
  gap:.7rem;
  padding:1.1rem .9rem;
  /* Grid items default to min-width:auto and refuse to shrink below their
     content — the blowout that pushed the gallery grid off-screen once. */
  min-width:0;
}
.tile-select .tile-head{ justify-content:center; }
.tile-select .tile-text{ width:100%; min-width:0; }
.tile-select .tile-sub{
  white-space:normal;
  overflow:visible;
  text-overflow:clip;
}
/* The art scales with the CARD, not the window — a percentage of the tile it
   sits in. Fewer featured sections means wider cards means bigger art, which is
   the point: three cards should feel more generous than four, not like four
   with a gap. A cqw here would measure .card-body and ignore how many cards
   share the row. */
.tile-select .tile-icon{
  order:-1;
  width:min(52%, 200px);
  height:auto;
  aspect-ratio:1;
  margin:0;
}

/* threshold raised from the original 481px: mobile no longer has stage
   padding or a card border (see the mobile-simplification block near the
   end of this file), so .card-body reads wider than before by that same
   amount — 560px keeps every phone, including the largest ones, safely
   below this and out of the tablet/desktop card-grid layout. */
@container (min-width:560px){
  /* Wider than a phone: as many as fit, up to four, then wrap.

     auto-FIT, not auto-fill — auto-fill keeps the empty tracks it could fit, so
     three cards in a box wide enough for four sat in three of four tracks and
     left a quarter of the row blank. auto-fit collapses the empties and the
     remaining tracks stretch to fill. The 25% floor is what caps the row at
     four: no fifth track can fit above that minimum.

     Everything else about the card is already set in the base rule. */
  .select-grid{
    grid-template-columns:repeat(auto-fit, minmax(calc(25% - .75rem), 1fr));
    gap:1rem;
  }
  .tile-select{
    padding:1.75rem 1.25rem;
    gap:1rem;
  }
}


/* ---------- news line, below the home cards ---------- */
/* Sits outside .card-body as its own flex item (a footer strip, mirroring
   how .chrome is a fixed header strip) so it's always visible even when
   the scrollable content above it (e.g. the tall desktop card grid) is
   taller than the window and needs to scroll — it never gets buried. */
.news-line{
  flex:0 0 auto;
  position:relative;
  /* Above the scrolling content, badges included. The glow marker on a card
     carries z-index:5 (it has to clear its neighbouring cards, which paint
     after it), so a ticker at z-index:1 had the star riding over the top of it
     as the grid scrolled past. This strip is chrome — nothing from the content
     area should ever be drawn on it. */
  z-index:6;
  margin:-24px 0 0 0; /* pulled up into .card-body's own bottom padding (min 24px) — never overlaps real content */
  /* fixed height, capacity for exactly 2 lines — a plain block so text wraps
     normally (display:flex here previously broke wrapping: mixed text/element
     children became separate flex items instead of one wrapping paragraph,
     which is what caused the stacking/jumping). Content never resizes this
     box; base.js shrinks the font instead if an item would need a 3rd line. */
  height:3.5rem;
  overflow:hidden;
  padding:.6rem clamp(1.5rem,4vw,3rem) 0;
  /* A true fade at the TOP edge — that's the only edge that means anything:
     it's what overlaps scrolled content as the card grid passes underneath
     (the strip pulls up -24px into .card-body's own padding), so it's the
     only place a fade has something different behind it to actually reveal.
     Plain black at the bottom, not the card's own background colour — a flat
     colour reads as intentional chrome no matter what's showing through the
     top, where the card background only worked for the dark theme.
     Extra stops instead of a single transparent->black run: a straight
     linear ramp only reaches full black at the very last pixel, so a thin
     high-contrast edge underneath (e.g. a tile's border) stays visible
     almost the whole strip instead of fading with everything else. Ramping
     to near-solid by the halfway point hides that without changing how the
     fade reads — still one smooth top-to-bottom black, just curved instead
     of linear. */
  background:linear-gradient(to bottom, transparent, rgba(0,0,0,.85) 50%, #000 70%);
  font-family:var(--font-mono);
  font-size:.7rem;
  line-height:1.6;
  letter-spacing:.02em;
  color:var(--muted);
}

/* Desktop-only extra reach for the fade above: on mobile, .card-body's own
   padding-bottom computes to exactly 24px — the base -24px margin is
   already at its safe ceiling there, with nothing to spare before the strip
   would start overlapping real content, which is exactly what that number
   was chosen to avoid. Desktop has real headroom (that padding is a
   clamp() that only ever grows from ~31px up on screens wide enough to
   reach this query), so only here does the overlap grow to let the fade
   reach a bit further — a glowing card's soft box-shadow bloom extends a
   little past its own edge, and 24px cut it off before the fade above ever
   got a chance to show anything. */
@media (min-width:769px){
  .news-line{ margin-top:-28px; }
}
.news-line .news-label{
  color:var(--fg);
  text-transform:uppercase;
  letter-spacing:.08em;
  margin-right:.5em;
}
.news-line a{
  color:var(--fg);
  text-decoration:underline;
  text-decoration-color:var(--muted);
}
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .news-line a:hover{ color:var(--card-border); }
}

/* ---------- horizontal hub tiles (archive / working-on listings, w/ thumb) ---------- */
.tile-h{ display:flex; gap:1rem; align-items:flex-start; }
.tile-h .tile-body{ flex:1 1 auto; min-width:0; }
.tile-h:focus-visible{ transform:none; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .tile-h:hover{ transform:none; }
}

/* ---------- "+ add" placeholder tiles — visible now, wired up in phase 2 ---------- */
.tile-add{
  display:flex;
  align-items:center;
  justify-content:center;
  border-style:dashed;
  color:var(--muted);
  cursor:not-allowed;
}
.tile-add:focus-visible{ border-color:var(--muted); transform:none; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .tile-add:hover{ border-color:var(--muted); transform:none; }
}
.tile-add-label{
  font-family:var(--font-mono);
  font-size:.72rem;
  letter-spacing:.05em;
  text-transform:uppercase;
  text-align:center;
}
.tile-add-note{
  display:block;
  margin-top:.4rem;
  font-size:.58rem;
  opacity:.75;
  text-transform:none;
  letter-spacing:.02em;
}

@media (max-width:480px){
  .tile-h{ flex-direction:column; }
}

/* ==========================================================================
   gallery — Instagram-style grid. Desktop/tablet: big image-forward cards
   with a caption underneath. Phone: pure square image grid, no captions.
   ========================================================================== */
.gallery-grid{
  display:grid;
  grid-template-columns:repeat(auto-fill, minmax(min(190px, 100%), 1fr));
  gap:1rem;
}
/* Narrow window (phones, or a shrunk desktop window) → exactly 2 columns.
   @container not @media: keyed to .card-body's width, so it stays correct
   when the desktop window itself is resized narrow. */
@container (max-width:560px){
  .gallery-grid{
    grid-template-columns:repeat(2, minmax(0, 1fr));
    gap:.6rem;
  }
}

/* The CARD carries the 3:4 ratio — not the image inside it. That's what
   guarantees every tile is the same shape regardless of how long its title
   is; the art area just flexes into whatever space the text leaves. */
.gallery-card{
  display:flex;
  flex-direction:column;
  aspect-ratio:3/4;
  /* grid/flex items default to min-width:auto, i.e. they refuse to shrink
     below their content's min-content width. The title + tag inside are
     wider than a 1fr track on a phone, which blew the tracks out past the
     container and pushed the grid off-screen. min-width:0 is the fix —
     never remove it from a grid item that holds text. */
  min-width:0;
  overflow:hidden;
  text-decoration:none;
  color:var(--fg);
  border:1px solid var(--muted);
  background:transparent;
  cursor:pointer;
  transition:border-color .18s ease, transform .12s ease, background-color .18s ease;
}
.gallery-card:focus-visible{ border-color:var(--fg); outline:none; }

.gallery-image{
  flex:1 1 auto;
  min-height:0; /* lets the art area actually shrink inside the fixed-ratio card */
  display:flex;
  align-items:center;
  justify-content:center;
  background:var(--grid-line);
  color:var(--muted);
  padding:16%;
  transition:color .18s ease;
}
.gallery-image .pixel-icon{ width:100%; height:100%; }

.gallery-text{
  flex:0 0 auto;
  display:flex;
  flex-direction:column;
  gap:.3rem;
  align-items:flex-start;
  padding:.55rem .6rem .6rem;
  border-top:1px solid var(--muted);
  font-family:var(--font-mono);
  min-width:0;
  overflow-wrap:anywhere; /* a long unbroken title can't force the card wider */
}
.gallery-tag{
  font-size:.5rem;
  letter-spacing:.08em;
  text-transform:uppercase;
  color:var(--muted);
  border:1px solid var(--muted);
  padding:.15em .4em;
}
.gallery-title{
  font-size:.68rem;
  letter-spacing:.02em;
  text-transform:uppercase;
  line-height:1.3;
}

.gallery-add{
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:.4rem;
  aspect-ratio:3/4;
  min-width:0; /* see .gallery-card — same grid-blowout guard */
  overflow:hidden;
  border:1px dashed var(--muted);
  color:var(--muted);
  cursor:not-allowed;
  font-family:var(--font-mono);
  text-align:center;
  padding:.75rem;
}
.gallery-add-plus{ font-size:1.5rem; line-height:1; }
.gallery-add-label{ font-size:.6rem; letter-spacing:.04em; text-transform:uppercase; }
.gallery-add-note{ display:block; color:var(--muted); opacity:.8; margin-top:.2rem; font-size:.52rem; }

@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .gallery-card:hover{ border-color:var(--fg); transform:translateY(-2px); }
  .gallery-card:hover .gallery-image{ color:var(--card-border); }
}

/* ==========================================================================
   gallery case-study detail page — scrollable, landing-page-style sections
   ========================================================================== */
.reel-section{
  padding:2.5rem 0;
  border-top:1px dashed var(--muted);
}
.reel-section:first-child{ border-top:none; padding-top:0; }
.reel-section img{
  display:block;
  width:100%;
  height:auto;
  border:1px solid var(--muted);
  margin:1.25rem 0;
}
.reel-kicker{
  font-family:var(--font-mono);
  font-size:.62rem;
  letter-spacing:.1em;
  text-transform:uppercase;
  color:var(--muted);
  margin:0 0 .6rem 0;
}
.reel-section p{
  font-size:clamp(.95rem,2.2vw,1.05rem);
  line-height:1.7;
  max-width:62ch;
  margin:0 0 1rem 0;
}

/* ==========================================================================
   working-on project feed — dated posts, horizontal thumb header + body
   ========================================================================== */
.post-list{ display:flex; flex-direction:column; gap:2.25rem; }
.post{ border-top:1px dashed var(--muted); padding-top:1.75rem; }
.post:first-child{ border-top:none; padding-top:0; }

.post-head{ display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.1rem; }
.post-head .post-head-text{ flex:1 1 auto; min-width:0; }

.post-date{
  font-family:var(--font-mono);
  font-size:.62rem;
  letter-spacing:.1em;
  text-transform:uppercase;
  color:var(--muted);
  margin:0;
}
.post-title{
  font-family:var(--font-pixel);
  font-size:1rem;
  line-height:1.5;
  margin:.4rem 0 0 0;
}
.post-body{
  font-size:clamp(.95rem,2.2vw,1.02rem);
  line-height:1.7;
  max-width:62ch;
  margin:0 0 1.1rem 0;
}
.video-embed{
  position:relative;
  width:100%;
  max-width:560px;
  aspect-ratio:16/9;
  border:1px solid var(--muted);
  margin:0 0 1.1rem 0;
  background:var(--gray-1);
}
.video-embed iframe{
  position:absolute; inset:0;
  width:100%; height:100%;
  border:none;
}
.video-embed-placeholder{
  border-style:dashed;
  display:flex;
  align-items:center;
  justify-content:center;
  text-align:center;
  padding:1rem;
  color:var(--muted);
  font-family:var(--font-mono);
  font-size:.65rem;
  letter-spacing:.06em;
  text-transform:uppercase;
}
.btn-download{
  display:inline-flex;
  align-items:center;
  gap:.5em;
  font-family:var(--font-mono);
  font-size:.68rem;
  letter-spacing:.05em;
  text-transform:uppercase;
  color:var(--fg);
  border:1px solid var(--fg);
  padding:.6em 1em;
  text-decoration:none;
}
.btn-download[aria-disabled="true"]{
  cursor:not-allowed;
  color:var(--muted);
  border-color:var(--muted);
}
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .btn-download:hover{ background:var(--fg); color:var(--bg); }
  .btn-download[aria-disabled="true"]:hover{ background:none; color:var(--muted); }
}

.post-add{
  border:1px dashed var(--muted);
  padding:1.1rem;
  color:var(--muted);
  font-family:var(--font-mono);
  font-size:.68rem;
  letter-spacing:.04em;
  text-transform:uppercase;
  text-align:center;
  cursor:not-allowed;
}

@media (max-width:480px){
  .post-head{ flex-direction:column; }
}

/* ==========================================================================
   mobile simplification — an app shell. The card becomes a fixed, full-
   screen container (the background, effectively): .chrome stays pinned as
   a header, .news-line stays pinned as a footer, and .card-body is the
   ONE scrolling region between them. This is a single scroll context (body
   itself is pinned/non-scrolling), not nested scrollers, which is what
   actually causes iOS scroll jank — a plain "let the whole page scroll"
   layout doesn't, since .card wasn't properly bounded there either (that's
   what made the gallery grid overflow). Placed last so it reliably wins
   over the component rules above it.
   ========================================================================== */
@media (max-width:768px){
  .scanlines, .vignette{ display:none; }

  html, body{ height:100%; overflow:hidden; }

  .stage{ padding:0; min-height:0; height:100%; }
  .card{
    position:fixed;
    inset:0;
    width:100%;
    height:100vh;
    height:100dvh; /* dynamic viewport height — accounts for the address bar; ignored by browsers that don't support it, 100vh above still applies then */
    border:none;
  }
  .card-body{
    overflow-y:auto;
    -webkit-overflow-scrolling:touch;
    /* room so the last row of content clears the fixed footer as you scroll */
    padding-bottom:1.5rem;
  }

  /* Same overlap trick as desktop, not margin:0 — without it the fade's
     transparent top edge has nothing behind it but more of .card's own flat
     background, so it just looks solid (this was the very first version of
     the desktop bug, mobile just never got the fix). -24px matches
     .card-body's own padding-bottom right above, so it only ever eats into
     that reserved space, never real content. */
  .news-line{
    margin-top:-24px;
    height:auto;
    min-height:3.25rem;
    padding-bottom:calc(.6rem + env(safe-area-inset-bottom, 0px));
  }

  /* Forward stays removed on mobile — native gestures cover it, and there's
     no equivalent fixed-position control for it the way Back gets below.
     Back itself now lives in the bar on every non-home page (data-crumb has
     more than one step — see the has-back class in app/view.php), a fixed
     way back to Home that doesn't depend on how you arrived. */
  .nav-btn{ display:none; }

  body.has-back #navBack{
    display:inline-flex;
    width:1.5em;
    opacity:.9;
  }

  /* A direct load or refresh of an inner page lands here with the button
     already in place — no animation, it's just part of the chrome from the
     first frame, same as every other control in the bar. Only arriving FROM
     the home screen gets the entrance below (see initChromeNav() in base.js,
     which checks document.referrer against the home URL before adding
     .back-enter). Width, not just opacity/transform: .chrome-left and .crumb
     are flex siblings in the same row, so animating the button from 0 to its
     resting 1.5em width is what pushes the breadcrumb over to make room for
     it, with no separate margin to keep in sync. */
  body.has-back.back-enter #navBack{
    animation:back-btn-in .38s cubic-bezier(.2,.9,.3,1.25) both;
  }
  @media (prefers-reduced-motion: reduce){
    body.has-back.back-enter #navBack{ animation:none; }
  }

  /* the bottom text toggle is replaced by a small icon up in the chrome bar */
  .os-controls{ display:none; }
  .theme-icon-btn{ display:inline-flex; }
}

/* Only ever played inside the max-width:768px block above, but keyframes
   aren't scoped by the media query that references them — defined here,
   next to the one rule that uses it. */
@keyframes back-btn-in{
  from{ width:0; opacity:0; transform:translateX(-6px); }
  to  { width:1.5em; opacity:.9; transform:translateX(0); }
}

.theme-icon-btn{
  display:none; /* shown on mobile only, see above */
  align-items:center;
  justify-content:center;
  width:22px;
  height:22px;
  flex-shrink:0;
  background:none;
  border:none;
  color:inherit;
  font-size:.85rem;
  line-height:1;
  padding:0;
  cursor:pointer;
  opacity:.9;
}
.theme-icon-btn:focus-visible{ outline:1px dashed currentColor; outline-offset:2px; }
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  .theme-icon-btn:hover{ opacity:1; }
}

/* ==========================================================================
   press feedback — the same visual language as :hover, moved onto the click.
   DELIBERATELY NOT inside any media query: this fires for mouse clicks and
   finger taps alike. (It used to be gated behind @media (hover:none), which
   meant a desktop browser resized to phone width — still a mouse, so
   hover:none is false — got hover but no click feedback at all.)
   Must stay AFTER the hover blocks above: :hover and :active have identical
   specificity, so source order is what makes the pressed state win while
   the pointer is held down.
   Transitions are very short on purpose — feedback that eases in over 200ms
   reads as lag. base.js also registers a no-op passive touchstart listener
   on the document, which iOS requires before it will apply :active at all.
   ========================================================================== */
/* suppress the browser's own grey flash — we draw our own feedback */
a, button, .tile, .gallery-card, [role="button"], [tabindex]{
  -webkit-tap-highlight-color:transparent;
}

.tile, .gallery-card{ transition:border-color .07s ease, transform .07s ease, background-color .07s ease; }

.tile:active,
.gallery-card:active{
  border-color:var(--fg);
  background:var(--grid-line);
  transform:scale(.97);
}
.tile:active .tile-icon,
.gallery-card:active .gallery-image{ color:var(--card-border); }
.tile:active .tile-cursor{ opacity:1; }
.tile:active .thumb-box{ border-color:var(--fg); color:var(--card-border); }

.btn-download:active,
.os-btn:active{ background:var(--fg); color:var(--bg); }

.nav-btn:active,
.chrome-home-icon:active,
.theme-icon-btn:active{ opacity:1; transform:scale(.88); }

.crumb a:active{ opacity:1; text-decoration:underline; }
.news-line a:active{ color:var(--card-border); }

/* disabled placeholders: acknowledge the press without implying it worked */
.tile-add:active,
.gallery-add:active,
.post-add:active,
.btn-download[aria-disabled="true"]:active{
  background:none;
  color:var(--muted);
  transform:none;
  border-color:var(--muted);
}

/* ==========================================================================
   reduced motion — always last, always wins
   ========================================================================== */
@media (prefers-reduced-motion: reduce){
  *, *::before, *::after{
    animation-duration:0.01ms !important;
    animation-iteration-count:1 !important;
    transition-duration:0.01ms !important;
  }
}

/* ---------------------------------------------------------------------------
   Tag filter — an icon at the right of the title row, opening a list of tags.

   It sits in the same row as the <h1> and is centre-aligned to it, so the page
   reads as "title … filter" on one line rather than as a second bar of chrome
   competing with the work below. The options are real ?tag= links; base.js
   upgrades them to instant filtering.
   --------------------------------------------------------------------------- */
.page-head{
  display:flex;
  align-items:center;      /* the filter is centred on the title, not its cap-line */
  justify-content:space-between;
  gap:1rem;
}

/* .heading carries its own bottom margin; in this row the two children would
   then sit on different baselines. Move the spacing to the row itself. */
.page-head > .heading{ margin-bottom:0; }
.page-head{ margin-bottom:.9rem; }

.filter-menu{
  position:relative;
  flex:0 0 auto;
}

.filter-btn{
  display:inline-flex;
  align-items:center;
  gap:.45em;
  background:none;
  border:1px solid var(--muted);
  color:var(--muted);
  font-family:var(--font-mono);
  font-size:.58rem;
  letter-spacing:.1em;
  text-transform:uppercase;
  padding:.4em .65em;
  cursor:pointer;
  transition:color .15s ease, border-color .15s ease;
}

.filter-btn .pixel-icon{
  width:12px;
  height:12px;
  background-color:currentColor;
  -webkit-mask-size:contain; mask-size:contain;
  -webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
  -webkit-mask-position:center; mask-position:center;
  flex:0 0 auto;
}

/* Open, or filtered — either way the control is doing something, and says so. */
.filter-menu.is-open .filter-btn,
.filter-menu.is-filtered .filter-btn{
  color:var(--fg);
  border-color:var(--fg);
}

@media (hover:hover) and (pointer:fine){
  .filter-btn:hover{ color:var(--fg); border-color:var(--fg); }
}
@media (hover:none){
  .filter-btn:active{ color:var(--fg); border-color:var(--fg); transition-duration:.06s; }
}
.filter-btn:focus-visible{ outline:2px solid var(--fg); outline-offset:2px; }

/* Anchored to the button's right edge so a long tag name grows leftward into
   the page rather than off the side of the window. */
.filter-list{
  position:absolute;
  top:calc(100% + .35rem);
  right:0;
  z-index:40;
  min-width:max(9rem, 100%);
  display:flex;
  flex-direction:column;
  background:var(--card-bg);
  border:1px solid var(--card-border);
  padding:.25rem;
}
.filter-list[hidden]{ display:none; }

.filter-option{
  display:flex;
  align-items:center;
  gap:.5em;
  font-family:var(--font-mono);
  font-size:.58rem;
  letter-spacing:.06em;
  text-transform:uppercase;
  color:var(--muted);
  text-decoration:none;
  padding:.5em .6em;
  white-space:nowrap;
}

/* A fixed-width gutter for the ◆, so the labels line up whether or not a row
   is the active one — without it every option shifts when the mark moves. */
.filter-option-mark{
  width:1em;
  flex:0 0 auto;
  color:var(--card-border);
}

.filter-option.is-active{ color:var(--fg); }

@media (hover:hover) and (pointer:fine){
  .filter-option:hover{ color:var(--fg); background:var(--grid-line); }
}
@media (hover:none){
  .filter-option:active{ color:var(--fg); background:var(--grid-line); transition-duration:.06s; }
}
.filter-option:focus-visible{ outline:1px dashed currentColor; outline-offset:-2px; }

/* Narrow windows: the icon keeps its place beside the title, but the tag name
   beside it is the first thing to go. @container, not @media — the window is
   user-resizable, so "narrow" is a property of the window. */
@container (max-width:520px){
  .filter-btn-label{ display:none; }
}

/* Free block content on a section's own page, above and below its list. The
   spacing is the only thing that differs from a case study's .case-body. */
.section-above{ margin-bottom:.4rem; }
.section-below{ margin-top:2rem; }

/* The filter hides cards with the `hidden` attribute. .gallery-card and .tile
   both set display, which would otherwise win over the UA's [hidden] rule. */
.gallery-card[hidden], .tile[hidden], [data-filter-empty][hidden]{ display:none !important; }

/* While the window is being dragged or resized, iframes must not take the
   pointer — see setWindowInteracting() in base.js. An embedded page or app is a
   separate document, and the moment the cursor crossed into one the parent
   stopped receiving mousemove, so dragging the handle inward went dead. */
body.is-window-interacting iframe{ pointer-events:none; }
/* Belt and braces: the drag itself should never start selecting text either. */
body.is-window-interacting{ cursor:default; }

/* ---------------------------------------------------------------------------
   Inline text formatting — see rich_inline() in app/helpers.php.

   Tints are named tokens rather than hex, so they follow the theme. In 8-bit
   they map to the arcade accents. In 1-bit they deliberately do NOT become
   colours: the whole premise of that theme is two of them, and quietly adding a
   third to honour a tint would break it. Emphasis is carried by weight and rule
   instead, so the same text still reads as marked-up in both.
   --------------------------------------------------------------------------- */
:root{
  --tx-accent:var(--fg);
  --tx-alt:var(--muted);
  --tx-warn:var(--fg);
}

body.arcade-mode{
  --tx-accent:var(--accent-a);
  --tx-alt:var(--accent-b);
  --tx-warn:var(--accent-c);
}

.tx-accent{ color:var(--tx-accent); }
.tx-alt{ color:var(--tx-alt); }
.tx-warn{ color:var(--tx-warn); }
.tx-muted{ color:var(--muted); }
.tx-fg{ color:var(--fg); }

/* 1-bit substitutes: the tint has to show up as something. */
:root:not(.arcade-mode) .tx-accent,
body:not(.arcade-mode) .tx-accent{
  text-decoration:underline;
  text-underline-offset:.18em;
  text-decoration-thickness:1px;
}
body:not(.arcade-mode) .tx-warn{ font-weight:700; }

/* Sizes are em-based, so a step inside a heading is relative to that heading
   rather than to some fixed scale — the same [s:lg] works in body copy and in
   a title without needing two vocabularies. */
.tx-xs{ font-size:.75em; }
.tx-sm{ font-size:.875em; }
.tx-lg{ font-size:1.35em; }
.tx-xl{ font-size:1.8em; }

/* A size step shouldn't drag the line box around when it sits mid-sentence. */
.tx-lg, .tx-xl{ line-height:1.1; display:inline-block; vertical-align:baseline; }

.cb code, code{
  font-family:var(--font-mono);
  font-size:.9em;
  padding:.1em .35em;
  border:1px solid var(--muted);
}

/* An image sitting inside a run of text — [i:<id>]. Sized in em so it matches
   whatever it's dropped into, from a card label to a heading, and nudged off
   the baseline so it sits optically centred rather than perched on it.
   Animated GIF/WebP/SVG all play here; nothing special is needed for that. */
.tx-icon{
  display:inline-block;
  width:1.15em;
  height:1.15em;
  vertical-align:-.22em;
  object-fit:contain;
}
/* The mask variant inherits the text colour, so a monochrome icon flips with
   the theme instead of staying stuck in one. */
span.tx-icon{ background-color:currentColor; }

/* ---------------------------------------------------------------------------
   Eased reflow inside the window.

   The window is resizable, and the layouts inside it respond to ITS width via
   container queries — so every drag of the handle re-lays the content out. Done
   instantly that reads as the contents snapping between two states; a short
   ease makes it read as the same layout adjusting.

   Fast on purpose. This is feedback during a drag that's already happening, not
   an entrance: anything slower than ~.18s trails the cursor and feels like lag,
   which is the opposite of the problem being solved.

   Only properties that actually change at a breakpoint are listed. Transitioning
   everything would drag the whole page into the compositor on every pointer move
   during a resize.
   --------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) and (min-width:769px){
  .select-grid,
  .gallery-grid,
  .tile-grid{
    transition:grid-template-columns .18s ease, gap .18s ease;
  }

  /* The cards animate their own box as the tracks resize under them, so a
     column count change reads as the grid rearranging rather than as a cut. */
  .tile-select,
  .tile,
  .gallery-card{
    transition:
      border-color .18s ease,
      background-color .18s ease,
      padding .18s ease,
      width .18s ease,
      height .18s ease,
      aspect-ratio .18s ease;
  }

  .card-body{ transition:padding .18s ease; }

  .hero-row,
  .eyebrow,
  .description,
  .heading{ transition:font-size .18s ease; }
}

/* Only the type scale stands down during a live drag. Transitioning font-size
   on every pointer move forces a full re-layout of the text each frame, which
   is where the lag actually came from — the grid tracks are cheap by
   comparison. Those keep animating THROUGH the drag, which is the whole point:
   the cards visibly slide between columns as the window crosses a breakpoint,
   rather than teleporting. */
body.is-window-interacting .hero-line,
body.is-window-interacting .hero-row,
body.is-window-interacting .eyebrow,
body.is-window-interacting .description,
body.is-window-interacting .heading{
  transition:none !important;
}

/* ---------------------------------------------------------------------------
   Author-written links — [text](url) and bare URLs.

   The site's base rule is `a{color:inherit}`, which is right for the chrome
   (breadcrumb, tiles, cards: the whole element is the target, so a differently
   coloured word inside it would be noise). It is wrong for a link inside a
   sentence, where nothing else marks the word as clickable. These carry their
   own class so the two cases can differ without fighting.
   --------------------------------------------------------------------------- */
.tx-link{
  color:var(--tx-accent);
  text-decoration:underline;
  text-underline-offset:.18em;
  text-decoration-thickness:1px;
  transition:color .15s ease, background-color .15s ease, text-decoration-color .15s ease;
}

/* In 1-bit there is no accent to spend, so the underline does the whole job —
   and thickens on hover rather than changing hue. */
body:not(.arcade-mode) .tx-link{
  color:var(--fg);
  text-decoration-color:var(--muted);
}

/* This has to be written at the SAME specificity as the rule above or it loses.
   `body:not(.arcade-mode) .tx-link` scores (0,2,1); a bare `.tx-link:hover` only
   (0,2,0). So the theme colour beat the hover colour: the background went white
   and the text stayed white, and the link disappeared under the cursor. */
body:not(.arcade-mode) .tx-link:hover{ color:var(--card-bg); }

@media (hover:hover) and (pointer:fine){
  .tx-link:hover{
    background:var(--fg);
    color:var(--card-bg);
    text-decoration-color:transparent;
  }
  body.arcade-mode .tx-link:hover{
    background:var(--tx-accent);
    color:var(--bg);
  }
}
@media (hover:none){
  .tx-link:active{ background:var(--fg); color:var(--card-bg); transition-duration:.06s; }
}

.tx-link:focus-visible{ outline:2px solid currentColor; outline-offset:2px; }

/* Off-site links say so. A marker rather than a colour, because colour is
   already carrying "this is a link" and can't also carry "this leaves". */
.tx-link-out::after{
  content:'↗';
  font-size:.8em;
  margin-left:.15em;
  vertical-align:.15em;
  opacity:.7;
}


/* ---------------------------------------------------------------------------
   The square About button in the hero row.

   Deliberately a square with an icon and no label: it sits beside a wordmark,
   and a second piece of text there would compete with it. The icon is whatever
   you upload — a GIF, a photo, a drawing, a flat SVG that recolours with the
   theme — so it can carry the personality the word "About" wouldn't.
   --------------------------------------------------------------------------- */
.about-btn{
  flex:0 0 auto;
  position:relative;
  display:flex;
  align-items:center;
  justify-content:center;
  /* .95em against the hero row, where the star logo is 1.05em — so it sits
     just under the mark at every width, by construction rather than by tuning.
     As large as "never bigger than the star" allows: the name and the mark are
     the important things here, and this is a way in, not a third headline. */
  width:var(--hero-about, .95em);
  height:var(--hero-about, .95em);
  /* Visible, not hidden: the rotating sweep sits just outside the box and was
     being clipped away entirely. The icon can't overflow anyway — it's sized to
     exactly 100% with object-fit. An opaque background keeps the sweep to the
     edges instead of glowing through the middle behind the icon. */
  /* OPAQUE, and that matters for more than the glow: the hero wordmark is free
     to run past its row on a narrow window, and a solid box is what lets it
     slide cleanly underneath this button instead of tangling with the icon.
     Two layers because --card-bg is translucent in 8-bit. */
  overflow:visible;
  background-color:var(--card-bg);
  background-image:linear-gradient(var(--bg), var(--bg));
  border:2px solid var(--card-border);
  color:var(--fg);
  text-decoration:none;
  transition:border-color .18s ease, transform .18s ease, background-color .18s ease;
}

.about-btn img{
  width:100%;
  height:100%;
  object-fit:cover;
  display:block;
}
.about-btn .pixel-icon{ width:64%; height:64%; }

@media (min-width:769px){
  /* Desktop-only bump — same trick as the mobile multipliers on .about-btn
     further down (1.35 / 1.15): scaled on top of the shared --hero-about em
     value instead of raising --hero-about itself, so this doesn't also grow
     anything else that's sized off that variable. Must come after the base
     rule above to win the cascade (same specificity, source order decides). */
  .about-btn{
    width:calc(var(--hero-about, .95em) * 1.3);
    height:calc(var(--hero-about, .95em) * 1.3);
  }

  /* .hero-line centers .about-tile as a whole (button + gap + label) against
     .hero-row, which pulls the BUTTON above the wordmark's true centre — half
     the label block's height too high, since the label hangs below it inside
     the same centred box. Nudge the tile down by that same half to put the
     button itself, not the button+label stack, on the wordmark's centreline.
     Measured against the rendered label + gap at this breakpoint (fixed rem
     sizes, so it doesn't drift with --hero-scale or window width). */
  .about-tile{ transform:translateY(.64rem); }
}

/* The About button's label. Always visible, never a tooltip: an icon alone
   doesn't say what it opens, and this is the only route to that page once the
   card is hidden from the home grid. Same font, colour and treatment as
   .eyebrow — it's the same kind of thing, a small piece of machine-voice
   labelling rather than a heading. */
.about-tile{
  flex:0 0 auto;
  position:relative;
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:.35rem;
  /* Sized by its contents, not clamped to the button.

     Clamping it to --hero-about seemed right — "a title the size of the box" —
     but the box is small and the result was "ABOUT M…", which tells you less
     than no label at all. The tile is now as wide as whichever of the two is
     wider, and the button stays centred over the word.

     (Worth remembering why the first attempt failed so badly: --hero-about is
     an `em` value, and em resolves against the font-size of the element using
     it. On the tile that's the hero's large size; on the label, .7rem — so the
     same token meant ~54px in one place and ~8px in the other.) */
  min-width:0;
}

/* The wordmark runs under this button when the window is narrow, and hitting
   the box's hard edge looked like a collision. This is a pad of page colour,
   wider than the button and fading out to the left, so the text dissolves a
   little before it arrives rather than being guillotined by a border.

   Positioned, so it paints over the inline text of .hero-row (an earlier,
   non-positioned sibling) while still sitting beneath the button and label,
   which are positioned and later in tree order. No z-index needed for that —
   painting order alone gets it right, and adding one would create a stacking
   context that the glow would then have to fight. */
.about-tile::before{
  content:'';
  position:absolute;

  /* Reaches out by exactly the card's own horizontal padding — the same
     clamp() .card-body uses — so the wordmark is cut at the site's margin
     rather than at some number invented for this one element. Extending RIGHT
     as well as left matters: with only a left pad, letters wide enough to clear
     the button reappeared on the far side of it. */
  top:-.5em;
  bottom:-.3em;
  left:calc(-.25 * clamp(1.5rem, 4vw, 3rem));
  right:calc(-1 * clamp(1.5rem, 4vw, 3rem));

  /* Opaque, in two layers because --card-bg is translucent in 8-bit and would
     let the wordmark show through a single one. */
  background-color:var(--card-bg);
  background-image:linear-gradient(var(--bg), var(--bg));

  /* Soft on the LEFT only, hard everywhere else. Masking rather than fading the
     colour keeps the fill genuinely opaque where it matters — a gradient in the
     background would have to fade toward transparent, which in 8-bit means
     fading toward a translucent card colour and letting text ghost through.
     The mask fades the whole stack instead, so the right-hand side stays a
     clean cut at the card's edge. */
  -webkit-mask-image:linear-gradient(to right, transparent 0, #000 1.3rem);
  mask-image:linear-gradient(to right, transparent 0, #000 1.3rem);

  pointer-events:none;
}

/* Exactly .eyebrow's voice — same family, size, spacing, case and colour. It is
   the same kind of thing: machine-voice labelling, not a heading. */
.about-tile-label{
  /* Positioned, so it paints AFTER .about-tile::before.

     The pad is a positioned descendant, and positioned descendants paint after
     in-flow content — so an in-flow label sat underneath it and vanished on
     desktop. (Mobile survived by accident: there the label is absolute, which
     made it positioned too, and later in tree order than the pad.) */
  position:relative;
  font-family:var(--font-mono);
  font-size:.7rem;
  letter-spacing:.08em;
  text-transform:uppercase;
  color:var(--muted);
  white-space:nowrap;
  text-align:center;
  transition:color .18s ease;
}

@media (min-width:769px){
  /* .about-tile's own width is whichever of the button or this label is
     wider (see the comment on .about-tile above) — so once the desktop
     button grew, the label (still its original size) became the wider one,
     and centering the button under it is what pushed the button in from
     the card's right edge instead of sitting flush with it like the maze
     and tiles below. Shrinking the label back under the button's width
     lets the button decide the tile's width again. */
  .about-tile-label{ font-size:.54rem; }
}

@media (hover:hover) and (pointer:fine){
  .about-tile:hover .about-tile-label{ color:var(--fg); }
}

.about-btn-glyph{
  font-family:var(--font-pixel);
  font-size:.34em;
  line-height:1;
}

/* The sweep IS the shimmer now. The earlier version was a linear sheen crossing
   the face, which needed overflow:hidden to stay inside the button — and that
   same clip removed the rotating glow. One effect that does both jobs is better
   than two that cancel each other. */

/* The same rotating light as a glowing card, at the button's scale. ::before is
   free here — ::after is the shimmer, and the two read as one effect: a sweep
   around the edge and a sheen across the face. */
/* The same construction as a glowing card — a masked band, so the light can
   never reach the face of the button whatever the stacking context does. More
   of it, though: this sits alone beside a wordmark rather than in a grid of
   cards, so it has nothing to be measured against and can afford to be read
   from further away. */
/* The same travelling light as a glowing card, by the same box-shadow method
   and for the same reasons — see .is-glowing. Tighter, because the button is a
   fraction of a card's size and the card's spread read as a cloud around it;
   brighter, because it sits alone beside a wordmark rather than in a grid, so
   there is nothing next to it to judge it against. */
.about-btn{
  animation:about-orbit 11s linear infinite;
}

@keyframes about-orbit{
  0%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.5),
      4px 0px 11px 0 rgba(255,255,255,0.12),
      0px 4px 11px 0 rgba(255,255,255,0.28),
      -4px 0px 11px 0 rgba(255,255,255,0.12);
  }
  14%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.5),
      4px 0px 11px 0 rgba(255,255,255,0.12),
      0px 4px 11px 0 rgba(255,255,255,0.28),
      -4px 0px 11px 0 rgba(255,255,255,0.12);
  }
  19%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.3),
      4px 0px 11px 0 rgba(255,255,255,0.072),
      0px 4px 11px 0 rgba(255,255,255,0.168),
      -4px 0px 11px 0 rgba(255,255,255,0.072);
  }
  25%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.12),
      4px 0px 11px 0 rgba(255,255,255,0.5),
      0px 4px 11px 0 rgba(255,255,255,0.12),
      -4px 0px 11px 0 rgba(255,255,255,0.28);
  }
  39%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.12),
      4px 0px 11px 0 rgba(255,255,255,0.5),
      0px 4px 11px 0 rgba(255,255,255,0.12),
      -4px 0px 11px 0 rgba(255,255,255,0.28);
  }
  44%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.072),
      4px 0px 11px 0 rgba(255,255,255,0.3),
      0px 4px 11px 0 rgba(255,255,255,0.072),
      -4px 0px 11px 0 rgba(255,255,255,0.168);
  }
  50%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.28),
      4px 0px 11px 0 rgba(255,255,255,0.12),
      0px 4px 11px 0 rgba(255,255,255,0.5),
      -4px 0px 11px 0 rgba(255,255,255,0.12);
  }
  64%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.28),
      4px 0px 11px 0 rgba(255,255,255,0.12),
      0px 4px 11px 0 rgba(255,255,255,0.5),
      -4px 0px 11px 0 rgba(255,255,255,0.12);
  }
  69%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.168),
      4px 0px 11px 0 rgba(255,255,255,0.072),
      0px 4px 11px 0 rgba(255,255,255,0.3),
      -4px 0px 11px 0 rgba(255,255,255,0.072);
  }
  75%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.12),
      4px 0px 11px 0 rgba(255,255,255,0.28),
      0px 4px 11px 0 rgba(255,255,255,0.12),
      -4px 0px 11px 0 rgba(255,255,255,0.5);
  }
  89%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.12),
      4px 0px 11px 0 rgba(255,255,255,0.28),
      0px 4px 11px 0 rgba(255,255,255,0.12),
      -4px 0px 11px 0 rgba(255,255,255,0.5);
  }
  94%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.072),
      4px 0px 11px 0 rgba(255,255,255,0.168),
      0px 4px 11px 0 rgba(255,255,255,0.072),
      -4px 0px 11px 0 rgba(255,255,255,0.3);
  }
  100%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 11px 0 rgba(255,255,255,0.5),
      4px 0px 11px 0 rgba(255,255,255,0.12),
      0px 4px 11px 0 rgba(255,255,255,0.28),
      -4px 0px 11px 0 rgba(255,255,255,0.12);
  }
}

body.arcade-mode .about-btn{ animation-name:about-orbit-arcade; }

@keyframes about-orbit-arcade{
  0%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.55),
      4px 0px 13px 0 rgba(255,43,214,0.14),
      0px 4px 13px 0 rgba(255,230,0,0.3),
      -4px 0px 13px 0 rgba(0,229,255,0.14);
  }
  14%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.55),
      4px 0px 13px 0 rgba(255,43,214,0.14),
      0px 4px 13px 0 rgba(255,230,0,0.3),
      -4px 0px 13px 0 rgba(0,229,255,0.14);
  }
  19%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.33),
      4px 0px 13px 0 rgba(255,43,214,0.084),
      0px 4px 13px 0 rgba(255,230,0,0.18),
      -4px 0px 13px 0 rgba(0,229,255,0.084);
  }
  25%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.14),
      4px 0px 13px 0 rgba(255,43,214,0.55),
      0px 4px 13px 0 rgba(255,230,0,0.14),
      -4px 0px 13px 0 rgba(0,229,255,0.3);
  }
  39%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.14),
      4px 0px 13px 0 rgba(255,43,214,0.55),
      0px 4px 13px 0 rgba(255,230,0,0.14),
      -4px 0px 13px 0 rgba(0,229,255,0.3);
  }
  44%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.084),
      4px 0px 13px 0 rgba(255,43,214,0.33),
      0px 4px 13px 0 rgba(255,230,0,0.084),
      -4px 0px 13px 0 rgba(0,229,255,0.18);
  }
  50%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.3),
      4px 0px 13px 0 rgba(255,43,214,0.14),
      0px 4px 13px 0 rgba(255,230,0,0.55),
      -4px 0px 13px 0 rgba(0,229,255,0.14);
  }
  64%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.3),
      4px 0px 13px 0 rgba(255,43,214,0.14),
      0px 4px 13px 0 rgba(255,230,0,0.55),
      -4px 0px 13px 0 rgba(0,229,255,0.14);
  }
  69%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.18),
      4px 0px 13px 0 rgba(255,43,214,0.084),
      0px 4px 13px 0 rgba(255,230,0,0.33),
      -4px 0px 13px 0 rgba(0,229,255,0.084);
  }
  75%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.14),
      4px 0px 13px 0 rgba(255,43,214,0.3),
      0px 4px 13px 0 rgba(255,230,0,0.14),
      -4px 0px 13px 0 rgba(0,229,255,0.55);
  }
  89%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.14),
      4px 0px 13px 0 rgba(255,43,214,0.3),
      0px 4px 13px 0 rgba(255,230,0,0.14),
      -4px 0px 13px 0 rgba(0,229,255,0.55);
  }
  94%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.084),
      4px 0px 13px 0 rgba(255,43,214,0.18),
      0px 4px 13px 0 rgba(255,230,0,0.084),
      -4px 0px 13px 0 rgba(0,229,255,0.33);
  }
  100%{
    animation-timing-function:linear;
    box-shadow:
      0px -4px 13px 0 rgba(0,229,255,0.55),
      4px 0px 13px 0 rgba(255,43,214,0.14),
      0px 4px 13px 0 rgba(255,230,0,0.3),
      -4px 0px 13px 0 rgba(0,229,255,0.14);
  }
}

@media (prefers-reduced-motion: reduce){
  .about-btn{ animation:none; box-shadow:0 0 11px 0 rgba(255,255,255,.34); }
}



/* Hover is a LIFT plus a tighter ring — movement, which the resting glow never
   has. That keeps the two states legible as different things. */
@media (hover:hover) and (pointer:fine) and (min-width:769px){
  /* Same signal a card gives: the border comes up to full strength and the
     thing lifts. The previous version only nudged it 2px and added a 1px ring,
     which the glow swallowed whole — there was feedback, it just couldn't be
     seen. No box-shadow here for that reason; the border is the tell. */
  .about-btn:hover{
    transform:translateY(-2px);
    border-color:var(--fg);
  }
  body.arcade-mode .about-btn:hover{ border-color:var(--accent-b); }
}

/* Press feedback, and NOT confined to touch — the cards' own :active rules
   aren't either. A click on a desktop deserves an answer too; it was only ever
   phones that needed the :hover rules kept away from them, which the
   (hover:hover) and (pointer:fine) guard above already handles. */
.about-btn:active{
  transform:scale(.94);
  border-color:var(--fg);
  background:var(--grid-line);
  transition-duration:.06s;
}
.about-btn:active .pixel-icon{ background-color:var(--fg); }
.about-btn:active .about-btn-glyph{ color:var(--fg); }

body.arcade-mode .about-btn:active{ border-color:var(--accent-b); }
.about-btn:focus-visible{ outline:2px solid var(--fg); outline-offset:3px; }

/* Phones. The star used to get an extra bump here on top of the row's own
   scale, growing relative to the wordmark on the theory that the mark reads
   faster than the letters at a glance. Removed: the star and the name are
   meant to hold one fixed ratio at every width (that's the whole point of
   sizing both in em off .hero-line's single font-size, right above), and a
   star-only multiplier broke exactly that guarantee on phones — the two
   drifted apart again, the same problem the em-based system exists to
   prevent. The button stays in the same place — it has to be reachable on
   both. */
@media (max-width:480px){
  /* A touch bigger than the desktop ratio, not smaller: it's a tap target on
     a phone. */
  .about-btn{ width:calc(var(--hero-about, .95em) * 1.15); height:calc(var(--hero-about, .95em) * 1.15); }


  .hero-line{ gap:.7rem; }
}

/* Screen-reader label for the icon-only button. Clip, not display:none — a
   hidden-by-display element is skipped by assistive tech entirely. */
.visually-hidden{
  position:absolute;
  width:1px; height:1px;
  padding:0; margin:-1px;
  overflow:hidden;
  clip:rect(0 0 0 0);
  clip-path:inset(50%);
  white-space:nowrap;
  border:0;
}

/* ---------------------------------------------------------------------------
   Glow — "look at this one".

   The hard part here isn't intensity, it's AMBIGUITY. A brighter border with a
   soft halo is what hover already does, so a stronger version of that just
   reads as "this card is hovered" — worse than no marking at all, because it
   tells the visitor something untrue about their own pointer.

   So the glow is built from things hover never does:

     1. a STAR BADGE pinned to the corner. Hover produces no new element, ever,
        so a mark that is simply present cannot be misread as a pointer state.
        The star is already this site's accent for "selected", so it needs no
        explaining.
     2. a coloured bloom — the accent in 8-bit, and in 1-bit the star colour,
        which is the one place that theme does allow a highlight.
     3. a double ring, well outside the card's own border, at a size hover has
        no reason to reach.

   Steady, not breathing: a pulse is caught at the bottom of its cycle half the
   time, so the marked card isn't reliably marked, and it doesn't survive a
   screenshot.
   --------------------------------------------------------------------------- */
.is-glowing{
  position:relative;
  /* Nothing else. The card's border, background and hover behaviour are all
     exactly as they were — see the glow below for why none of that matters
     any more. */
}

/* The notification badge. This is the part that makes the marking unambiguous:
   hover never adds an element, so a mark that is simply present cannot be
   mistaken for a pointer state.

   z-index has to be explicit and high. The card deliberately creates no
   stacking context (see above), so the badge competes directly with the sibling
   cards — and it overhangs into the next card's box, which paints later in DOM
   order and would otherwise cover it. */
.is-glowing::before{
  content:'★';
  position:absolute;
  top:-.6em;
  right:-.5em;
  z-index:5;
  width:1.7em;
  height:1.7em;
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:.8rem;
  line-height:1;
  color:var(--bg);
  background:var(--star);
  /* A ring in the page colour separates the badge from whatever it overlaps —
     the card's own border, or a neighbour in a tight grid. */
  border:2px solid var(--bg);
  pointer-events:none;

  /* ★ carries descender space in most faces, so flex centring alone leaves it
     sitting low. A hair of padding puts the shape's optical centre on the
     badge's centre, which is what the eye actually measures. */
  padding-bottom:.08em;
}

body.arcade-mode .is-glowing::before{
  background:var(--accent-c);
  color:var(--bg);
}

/* The light travelling round the outside of the card.

   Built from box-shadow, after two other approaches failed on the same point.
   An outer box-shadow is, by definition, painted only outside the border box —
   the browser clips the border box out of it. That gives three things for free
   that nothing else managed at once:

     · it can never appear inside the card, whatever the stacking context does
       (a z-index:-1 layer moved on top of the card the moment `transform` on
       hover created one),
     · it falls off naturally into the page, with no outer edge to read as a
       frame (a masked band always terminates somewhere),
     · and it leaves the border alone — blur with no spread doesn't thicken it.

   The rotation is four shadows chasing each other round the compass. A conic
   gradient would be smoother, but every way of clipping one to the outside of
   the card reintroduces one of the problems above. `mask-composite:exclude`
   in particular only cancels exactly where the other layer is fully opaque, so
   a faded mask leaves a wash across the card — which is precisely the artefact
   this replaced. */
/* The rhythm lives in the keyframes, not in one easing curve over the whole
   loop. Each quarter is: hold steady on one side, sag gently, then ease across
   to the next — with `animation-timing-function` set PER KEYFRAME so the sag
   eases in and the crossing eases out. A single curve on the animation would
   apply to the circuit as a whole and flatten the hesitations back out; a flat
   `linear` made them snap. Per-segment easing keeps the catch and rounds it
   off, which is what "smoothed out" means here.

   The dim only drops to 60% now rather than 35% — a shallow sag reads as the
   light breathing, a deep one as a dropped frame. */
/* The travelling glow lives on a pseudo-element, not on the card.

   `animation` is a shorthand, so whatever sets it last on an element wins
   outright — and the card itself is something other rules legitimately want to
   animate (the arrival, for one). Putting the glow on ::after gives it an
   element of its own that nothing else touches, so the two can never collide
   again no matter what is added later.

   inset:0 with no background means the only thing this paints is the shadow,
   which box-shadow renders OUTSIDE the box — so it rings the card without
   covering a pixel of it. border-radius is inherited so the ring follows the
   card's corners, and ::before (the ★ badge) is untouched. */
.is-glowing::after{
  content:'';
  position:absolute;
  inset:0;
  border-radius:inherit;
  pointer-events:none;
  animation:glow-orbit 11s linear infinite;
}

@keyframes glow-orbit{
  0%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.34),
      6px 0px 18px 0 rgba(255,255,255,0.08),
      0px 6px 18px 0 rgba(255,255,255,0.18),
      -6px 0px 18px 0 rgba(255,255,255,0.08);
  }
  14%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.34),
      6px 0px 18px 0 rgba(255,255,255,0.08),
      0px 6px 18px 0 rgba(255,255,255,0.18),
      -6px 0px 18px 0 rgba(255,255,255,0.08);
  }
  19%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.204),
      6px 0px 18px 0 rgba(255,255,255,0.048),
      0px 6px 18px 0 rgba(255,255,255,0.108),
      -6px 0px 18px 0 rgba(255,255,255,0.048);
  }
  25%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.08),
      6px 0px 18px 0 rgba(255,255,255,0.34),
      0px 6px 18px 0 rgba(255,255,255,0.08),
      -6px 0px 18px 0 rgba(255,255,255,0.18);
  }
  39%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.08),
      6px 0px 18px 0 rgba(255,255,255,0.34),
      0px 6px 18px 0 rgba(255,255,255,0.08),
      -6px 0px 18px 0 rgba(255,255,255,0.18);
  }
  44%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.048),
      6px 0px 18px 0 rgba(255,255,255,0.204),
      0px 6px 18px 0 rgba(255,255,255,0.048),
      -6px 0px 18px 0 rgba(255,255,255,0.108);
  }
  50%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.18),
      6px 0px 18px 0 rgba(255,255,255,0.08),
      0px 6px 18px 0 rgba(255,255,255,0.34),
      -6px 0px 18px 0 rgba(255,255,255,0.08);
  }
  64%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.18),
      6px 0px 18px 0 rgba(255,255,255,0.08),
      0px 6px 18px 0 rgba(255,255,255,0.34),
      -6px 0px 18px 0 rgba(255,255,255,0.08);
  }
  69%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.108),
      6px 0px 18px 0 rgba(255,255,255,0.048),
      0px 6px 18px 0 rgba(255,255,255,0.204),
      -6px 0px 18px 0 rgba(255,255,255,0.048);
  }
  75%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.08),
      6px 0px 18px 0 rgba(255,255,255,0.18),
      0px 6px 18px 0 rgba(255,255,255,0.08),
      -6px 0px 18px 0 rgba(255,255,255,0.34);
  }
  89%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.08),
      6px 0px 18px 0 rgba(255,255,255,0.18),
      0px 6px 18px 0 rgba(255,255,255,0.08),
      -6px 0px 18px 0 rgba(255,255,255,0.34);
  }
  94%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.048),
      6px 0px 18px 0 rgba(255,255,255,0.108),
      0px 6px 18px 0 rgba(255,255,255,0.048),
      -6px 0px 18px 0 rgba(255,255,255,0.204);
  }
  100%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 18px 0 rgba(255,255,255,0.34),
      6px 0px 18px 0 rgba(255,255,255,0.08),
      0px 6px 18px 0 rgba(255,255,255,0.18),
      -6px 0px 18px 0 rgba(255,255,255,0.08);
  }
}

/* 8-bit has accents to spend, so the travelling light carries colour. */
body.arcade-mode .is-glowing::after{ animation-name:glow-orbit-arcade; }

@keyframes glow-orbit-arcade{
  0%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.4),
      6px 0px 20px 0 rgba(255,43,214,0.1),
      0px 6px 20px 0 rgba(255,230,0,0.22),
      -6px 0px 20px 0 rgba(0,229,255,0.1);
  }
  14%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.4),
      6px 0px 20px 0 rgba(255,43,214,0.1),
      0px 6px 20px 0 rgba(255,230,0,0.22),
      -6px 0px 20px 0 rgba(0,229,255,0.1);
  }
  19%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.24),
      6px 0px 20px 0 rgba(255,43,214,0.06),
      0px 6px 20px 0 rgba(255,230,0,0.132),
      -6px 0px 20px 0 rgba(0,229,255,0.06);
  }
  25%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.1),
      6px 0px 20px 0 rgba(255,43,214,0.4),
      0px 6px 20px 0 rgba(255,230,0,0.1),
      -6px 0px 20px 0 rgba(0,229,255,0.22);
  }
  39%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.1),
      6px 0px 20px 0 rgba(255,43,214,0.4),
      0px 6px 20px 0 rgba(255,230,0,0.1),
      -6px 0px 20px 0 rgba(0,229,255,0.22);
  }
  44%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.06),
      6px 0px 20px 0 rgba(255,43,214,0.24),
      0px 6px 20px 0 rgba(255,230,0,0.06),
      -6px 0px 20px 0 rgba(0,229,255,0.132);
  }
  50%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.22),
      6px 0px 20px 0 rgba(255,43,214,0.1),
      0px 6px 20px 0 rgba(255,230,0,0.4),
      -6px 0px 20px 0 rgba(0,229,255,0.1);
  }
  64%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.22),
      6px 0px 20px 0 rgba(255,43,214,0.1),
      0px 6px 20px 0 rgba(255,230,0,0.4),
      -6px 0px 20px 0 rgba(0,229,255,0.1);
  }
  69%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.132),
      6px 0px 20px 0 rgba(255,43,214,0.06),
      0px 6px 20px 0 rgba(255,230,0,0.24),
      -6px 0px 20px 0 rgba(0,229,255,0.06);
  }
  75%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.1),
      6px 0px 20px 0 rgba(255,43,214,0.22),
      0px 6px 20px 0 rgba(255,230,0,0.1),
      -6px 0px 20px 0 rgba(0,229,255,0.4);
  }
  89%{
    animation-timing-function:ease-in;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.1),
      6px 0px 20px 0 rgba(255,43,214,0.22),
      0px 6px 20px 0 rgba(255,230,0,0.1),
      -6px 0px 20px 0 rgba(0,229,255,0.4);
  }
  94%{
    animation-timing-function:ease-out;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.06),
      6px 0px 20px 0 rgba(255,43,214,0.132),
      0px 6px 20px 0 rgba(255,230,0,0.06),
      -6px 0px 20px 0 rgba(0,229,255,0.24);
  }
  100%{
    animation-timing-function:linear;
    box-shadow:
      0px -6px 20px 0 rgba(0,229,255,0.4),
      6px 0px 20px 0 rgba(255,43,214,0.1),
      0px 6px 20px 0 rgba(255,230,0,0.22),
      -6px 0px 20px 0 rgba(0,229,255,0.1);
  }
}

@media (prefers-reduced-motion: reduce){
  .is-glowing::after{
    animation:none;
    box-shadow:0 0 18px 0 rgba(255,255,255,.24);
  }
}

@keyframes glow-rotate{
  to{ --glow-angle:360deg; }
}

/* Brightness only. Scaling would pump the bloom in and out from the edge, and
   the edge is the thing it's supposed to be pinned to. */
@keyframes glow-breathe{
  0%,100%{ opacity:.33; }
  50%    { opacity:.66; }
}

@media (prefers-reduced-motion: reduce){
}

/* No hover rules here on purpose. A glowing card hovers exactly like any other
   card, and the light keeps turning through it — the glow says "this one" about
   the card, hover says "this one" about the pointer. Different questions;
   neither should overwrite the other. */

/* The overflow:hidden on some card types would clip a badge hanging off the
   corner, so those get it inset instead of overhanging. */
.gallery-card.is-glowing{ overflow:visible; }

/* The rotation is driven by animating a registered custom property rather than
   by spinning an element: rotating a box sized to a non-square card throws its
   corners outside the halo and the sweep wobbles. Animating the angle keeps the
   geometry still and moves only the light, so it reads the same on a tall card
   as on a wide one. */
@property --glow-angle{
  syntax:'<angle>';
  initial-value:0deg;
  inherits:false;
}

/* The About button on mobile.

   Last in the file on purpose: these share their specificity with the base
   rules above, so source order decides — an earlier copy of this block sat
   before them and silently lost that argument.

   Bigger box, smaller caption, so the caption fits under the square rather than
   sticking out either side. Hover is handled above: that block is width-gated
   now too, so a phone-sized layout only ever gets the press state. */
@media (max-width:768px){
  /* The row scales down a touch as a whole — star and wordmark together,
     same ratio intact, since both are sized in em off this one number. A
     star-only multiplier used to sit here too, growing it relative to the
     wordmark; removed for the same reason as the 480px query above — the
     two are meant to hold one fixed ratio at every width, not drift apart
     on small screens. */
  .hero-line{ font-size:calc(clamp(1.5rem, 8vw, 4rem) * var(--hero-scale, 1) * .88); }
  /* A fixed 10px, not rem-based like the desktop .9rem — star and wordmark
     read as a noticeably closer pair on a phone-width screen. */
  .hero-row{ gap:10px; }

  .about-btn{
    width:calc(var(--hero-about, .95em) * 1.35);
    height:calc(var(--hero-about, .95em) * 1.35);
  }

  /* The BUTTON lines up with the middle of the wordmark, not the button-plus-
     caption stack. Taking the caption out of flow leaves the tile exactly the
     button's height, so .hero-line's align-items:center centres the thing you
     actually read it against. It stays on the right, and stays under the box. */
  .about-tile-label{
    position:absolute;
    top:calc(100% + .3rem);
    left:50%;
    transform:translateX(-50%);
    font-size:.5rem;
    letter-spacing:.02em;
    white-space:nowrap;
  }
}

/* ---------------------------------------------------------------------------
   Entrances.

   Both of these are opacity and transform only — the two properties a browser
   can animate on the compositor without touching layout or paint. Nothing here
   is scripted and nothing blocks rendering, so the page is not slower for it.
   Both sit behind prefers-reduced-motion, like every other animation here.
   --------------------------------------------------------------------------- */

@keyframes star-in{
  from{ opacity:0; transform:scale(.2); }
  to  { opacity:1; transform:scale(1); }
}

@media (prefers-reduced-motion: no-preference){
  /* The star grows into place. `backwards` and not `forwards`: the final frame
     is the element's normal state anyway, and holding it would outrank the
     hover rule below and kill the hover scale for the rest of the visit. */
  .chrome-home-icon{
    animation:star-in .42s cubic-bezier(.2,.9,.3,1.25) backwards;
  }
}

/* ---------------------------------------------------------------------------
   The page arrives top to bottom.

   Each block fades up in document order, the whole sequence inside a third of a
   second. It began as a phone-only fix — there the window is a fixed size and
   pages replace each other with no transition, so arriving somewhere new looked
   like nothing had happened — but it reads well at every width, so it is not
   gated on one. The window's own boot grow is a once-per-session thing on the
   home screen and does not overlap normal navigation.
   --------------------------------------------------------------------------- */
@keyframes rise-in{
  from{ opacity:0; transform:translateY(9px); }
  to  { opacity:1; transform:none; }
}

/* ---------------------------------------------------------------------------
   HOW LONG THE PAGE WAITS BEFORE IT STARTS ARRIVING.

   One number drives the whole sequence — the cards and, through them, the
   thumbnails. To undo the desktop hold entirely, set the desktop value to 0ms
   and everything returns to arriving immediately. Nothing else needs touching.

   Phones get 0: the window is a fixed size there, nothing is animating, and
   waiting would just be a delay.

   Desktop waits because the window itself is animating to this page's shape
   over 480ms (.card.is-animating in content.css), and cards rising in
   underneath a window that is still growing arrive twice — once as the window
   sweeps past them, once on their own.

   260ms rather than the full 480: the point is to miss the START of the window
   animation, not to wait out all of it. Overlapping its tail means the page is
   already filling in as the window settles, which is quicker than it sounds.
   --------------------------------------------------------------------------- */
:root{ --enter-delay:0ms; }

@media (min-width:769px){
  :root{ --enter-delay:260ms; }
}

@media (prefers-reduced-motion: no-preference){
  /* Grids are skipped here and their children staggered instead — animating a
     grid AND everything in it stacks two fades on the same pixels. */
  /* .is-glowing is excluded throughout. A glowing card carries its own
     permanent animation — animation:glow-orbit, swapped to glow-orbit-arcade
     in 8-bit and to none under reduced motion — and `animation` is a shorthand,
     so a second rule setting it on the same element REPLACES the first. This
     block sits later in the file than the glow does, which meant it silently
     turned the glow off altogether rather than merely delaying it.

     Running both would mean composing two animations in a list and keeping
     that list correct across all three of the glow's rules, which couples this
     to a system it has no business knowing about. A card that glows is already
     announcing itself; it does not need an entrance too. */
  .card-body > *:not(.tile-grid):not(.gallery-grid):not(.select-grid),
  .page-body > *:not(.tile-grid):not(.gallery-grid):not(.select-grid),
  .tile-grid > *,
  .gallery-grid > *,
  .select-grid > *{
    animation:rise-in .26s ease-out backwards;
  }

  .card-body > :nth-child(1),
  .page-body > :nth-child(1),
  .tile-grid > :nth-child(1),
  .gallery-grid > :nth-child(1),
  .select-grid > :nth-child(1){ animation-delay:calc(var(--enter-delay) + 0ms); }
  .card-body > :nth-child(2),
  .page-body > :nth-child(2),
  .tile-grid > :nth-child(2),
  .gallery-grid > :nth-child(2),
  .select-grid > :nth-child(2){ animation-delay:calc(var(--enter-delay) + 35ms); }
  .card-body > :nth-child(3),
  .page-body > :nth-child(3),
  .tile-grid > :nth-child(3),
  .gallery-grid > :nth-child(3),
  .select-grid > :nth-child(3){ animation-delay:calc(var(--enter-delay) + 70ms); }
  .card-body > :nth-child(4),
  .page-body > :nth-child(4),
  .tile-grid > :nth-child(4),
  .gallery-grid > :nth-child(4),
  .select-grid > :nth-child(4){ animation-delay:calc(var(--enter-delay) + 105ms); }
  .card-body > :nth-child(5),
  .page-body > :nth-child(5),
  .tile-grid > :nth-child(5),
  .gallery-grid > :nth-child(5),
  .select-grid > :nth-child(5){ animation-delay:calc(var(--enter-delay) + 140ms); }

  /* Everything past the fifth arrives together. A stagger that keeps counting
     makes a long page feel slow, which is the opposite of the point. */
  .card-body > :nth-child(n+6),
  .page-body > :nth-child(n+6),
  .tile-grid > :nth-child(n+6),
  .gallery-grid > :nth-child(n+6),
  .select-grid > :nth-child(n+6){ animation-delay:calc(var(--enter-delay) + 175ms); }

  /* A full-bleed app or embedded document IS the page; sliding it up leaves a
     gap under the title bar for a moment. It just fades. */
  .cb-app-full,
  .cb-doc{ animation:none; }
}

/* ---------------------------------------------------------------------------
   Thumbnails arrive last.

   The rise-in above runs 260ms per card and the last staggered one starts at
   175ms, so the cards are still arriving until 435ms. Thumbnails start at 240ms
   — into that window, not after it. The first cards are settled by then, so
   there is still a beat of structure before the pictures land, but the two
   halves overlap rather than running end to end, which reads as one arrival
   instead of two.

   Tied to the stagger above — change that and this wants changing with it.

   Only the picture fades, never the box around it: the frame is part of the
   card's own entrance, and fading the two together would just look like the
   card was slow. `backwards` holds it invisible through the delay instead of
   letting it flash in first.
   --------------------------------------------------------------------------- */
@keyframes thumb-in{
  from{ opacity:0; }
  to  { opacity:1; }
}

@media (prefers-reduced-motion: no-preference){
  .thumb-box img,
  .thumb-box .pixel-icon,
  .gallery-image img,
  .gallery-image .pixel-icon{
    animation:thumb-in .34s ease-out calc(var(--enter-delay) + 240ms) backwards;
  }
}
