/* ═══════════════════════════════════════════════════════════════════════
   phasmotic.studio — the shell.

   THERE IS NO COLOUR IN THIS FILE. Not a hex, not an rgb(), not a named
   colour, not `transparent`. Every paint goes through a token declared in
   css/tokens.css, which is the one file a winning identity candidate
   replaces. `tools/checks/source.mjs` asserts it, in both directions: no
   literal here, and no var(--…) here that tokens.css does not declare.

   `currentColor` and `inherit` appear and are not literals — they are
   indirections through a token somebody else set.

   Load order is tokens.css then this file, both render-blocking, both from
   this origin. That order matters only for readability; the cascade would
   resolve either way.

   THE CRITICAL CSS IS NOT HERE. The boot-fallback rules — the safety net that
   has to survive this stylesheet failing to arrive — are inline in every
   page's <head>, so a blocked stylesheet cannot take the net with it. They
   contain no colour either, which is the same decision from the other end: if
   this file never arrives, the page falls back to the user agent's own black
   on white and is completely readable. A dark ground set inline with the ink
   colour left to the UA is how that failure mode becomes black on black.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── Typefaces, served from this origin ────────────────────────────────────
   Two files, two variable faces, covering five body weights and three mono
   weights. Both SIL Open Font License 1.1, which is what permits hosting
   them; see assets/fonts/LICENSES.md.

   THERE IS NO DISPLAY FACE YET and `--f-display` falls back to the body
   stack. The two woff2 files still on disk from the tree this repository grew
   out of — orbitron and cinzel-verdant — are the game site's and are not
   referenced here. The display face is chosen with the identity candidate;
   its @font-face goes here and its family goes in tokens.css, together.

   font-display: swap: the typeface arrives when it arrives, and nothing waits
   for it. A preload needs `crossorigin` EVEN SAME-ORIGIN — without it the
   preload is a second download rather than a warm cache — which is why these
   are not preloaded at all rather than preloaded wrongly. */
/* The @font-face rules moved to css/fonts.css so the landing page can have
   the typefaces without the document stylesheet. Every page that links this
   file links that one too, immediately before it. */


/* ── Reset ────────────────────────────────────────────────────────────── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

/* The ground is painted on BOTH html and body. A background on either
   propagates to the canvas, and the contrast sweep reads the real painted
   stack off both before it grades anything — so this is the paint that
   `--ground` in tokens.css is cross-checked against. Change one, change the
   other. */
html {
  background: var(--ground);
  color-scheme: dark;
  /* A scripted scroll lands mid-flight under smooth scrolling, and every
     harness that scrolls has to set this to auto first. It is also motion
     nobody asked for. */
  scroll-behavior: auto;
  /* Reserved unconditionally, so the layout does not shift by a scrollbar
     width between a short page and a long one. */
  scrollbar-gutter: stable;
}

body {
  background: var(--ground);
  color: var(--text);
  font: 400 clamp(15px, 0.95rem + 0.2vw, 17px)/1.65 var(--f-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  /* Nothing on this site is wider than its column. If something ever is, it
     scrolls inside its own box — the document does not scroll sideways. */
  overflow-x: hidden;
}

img, svg, video { max-width: 100%; height: auto; display: block; }

/* ── Focus, ONE global rule ────────────────────────────────────────────────
   The sibling repository declares :focus-visible per control, which means a
   control added later ships with the user agent's default ring or with
   nothing at all, and nobody finds out. This is the whole site's answer, once.

   Two rings rather than one: the outline in --focus, and an offset drawn in
   --focus-behind so the ring reads whether the control sits on the ground or
   on a panel. `:focus:not(:focus-visible)` removes the ring for a mouse
   press, which is the only thing the UA default gets wrong here. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--focus-behind);
  border-radius: 3px;
}
:where(a, button, input, select, textarea, summary, [tabindex]):focus:not(:focus-visible) {
  outline: none;
}

/* ── The skip link ─────────────────────────────────────────────────────────
   A real one: first focusable thing in the document, on every page, and it
   moves focus rather than only the scroll position — which is why the target
   carries tabindex="-1".

   Hidden by clipping, NOT by `display: none` or `visibility: hidden`, both of
   which make it unfocusable and therefore make it decoration. It keeps its
   real colours in the clipped state on purpose: the contrast sweep measures
   it either way, and a link that is only legible once it is on screen is a
   link that was never measured. */
.skip {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 50;
  padding: 0.7rem 1.1rem;
  background: var(--ground-lift);
  color: var(--text);
  border: 1px solid var(--panel-edge-strong);
  border-radius: 0 0 4px 0;
  font: 500 0.875rem/1 var(--f-mono);
  text-decoration: none;
  clip-path: inset(50%);
}
.skip:focus { clip-path: none; }

/* ── The shared column ────────────────────────────────────────────────── */
.wrap {
  width: 100%;
  max-width: 68rem;
  margin-inline: auto;
  padding-inline: clamp(1.25rem, 5vw, 3rem);
}

/* ── Masthead ─────────────────────────────────────────────────────────── */
.masthead {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: center;
  /* Tall enough for the ambient layer to be a band rather than a hairline.
     It is also the only place on the page where a decorative layer is
     allowed, so it is the only place that needs the room. */
  min-height: clamp(9rem, 20vh, 14rem);
  padding-block: clamp(1.5rem, 4vw, 2.5rem);
  border-bottom: 1px solid var(--panel-edge);
  overflow: hidden;
}

.masthead-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: clamp(0.75rem, 3vw, 2rem);
  justify-content: space-between;
}

.wordmark {
  font: 700 clamp(1.35rem, 1rem + 1.6vw, 2rem)/1 var(--f-display);
  letter-spacing: 0.02em;
  color: var(--text);
  text-decoration: none;
  white-space: nowrap;
}
.wordmark .wordmark-suffix { color: var(--accent); }

/* ── Navigation ───────────────────────────────────────────────────────── */
.nav-list {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(0.9rem, 3vw, 1.75rem);
  list-style: none;
  font: 500 0.8125rem/1 var(--f-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.nav-list a {
  color: var(--text-dim);
  text-decoration: none;
  padding-block: 0.25rem;
  border-bottom: 1px solid var(--accent-deep);
}
.nav-list a:hover { color: var(--text); border-bottom-color: var(--accent); }
.nav-list a[aria-current='page'] { color: var(--text); border-bottom-color: var(--accent); }

/* ── The ambient layer ─────────────────────────────────────────────────────
   The only thing on this site that moves, and it is scoped to the masthead,
   where there is no small type — because the contrast sweep composites a
   decorative layer that is not an ancestor of the text as the page ground,
   so a bright drifting field under body copy is a real contrast failure the
   instrument would report as a pass.

   Two blobs on four coprime clocks — 41s, 29s, 13s and 11s — so the pattern
   does not land the same way twice within any session anybody will sit
   through. A rhythm somebody can learn stops reading as atmosphere and starts
   reading as a loop. LCM(41,29,13,11) is 169,867s, which is just under two
   days; that is the honest number, not "years".

   THE STRENGTH IS A CONTRAST DECISION, NOT A TASTE ONE. At its brightest the
   layer lifts the local background from L 0.0035 to about L 0.015, which
   leaves the 13px nav labels above it at roughly 6.8:1 — still over the floor.
   The contrast sweep CANNOT see this: a fixed decorative layer is not an
   ancestor of the text, so it composites the text against the page ground and
   would grade a blinding version as a pass. Raising these alphas is therefore
   a change no instrument in this repository will catch. Do the arithmetic.

   It is CSS, not canvas, and that was a decision rather than a default: flash
   safety on this site is measured by scrubbing document.getAnimations() a
   frame at a time, and a requestAnimationFrame render loop is not driven by
   that clock — tools/checks/flash.mjs fails outright rather than measuring a
   page it cannot step. Choosing canvas here would have made the flash check
   impossible, which is a choice you make once and discover much later. */
.ambient {
  position: absolute;
  /* Overhangs the masthead top and bottom so the blobs' bright centres sit
     mid-band rather than clipped at an edge, where the layer would be all
     falloff and no light. */
  inset: -60% -10%;
  z-index: -1;
  pointer-events: none;
  filter: blur(28px);
}
.ambient span {
  position: absolute;
  display: block;
  border-radius: 50%;
  will-change: transform, opacity;
}
.ambient .blob-a {
  left: 2%;
  top: 0;
  width: 46%;
  height: 100%;
  background: radial-gradient(circle at 50% 50%, var(--ambient-a) 0%, var(--ground) 72%);
  animation: drift-a 41s ease-in-out infinite, breathe 11s ease-in-out infinite;
}
.ambient .blob-b {
  right: 0;
  top: 0;
  width: 38%;
  height: 100%;
  background: radial-gradient(circle at 50% 50%, var(--ambient-b) 0%, var(--ground) 70%);
  animation: drift-b 29s ease-in-out infinite, breathe 13s ease-in-out infinite reverse;
}

@keyframes drift-a {
  0%   { transform: translate3d(0, 0, 0); }
  50%  { transform: translate3d(14%, 6%, 0); }
  100% { transform: translate3d(0, 0, 0); }
}
@keyframes drift-b {
  0%   { transform: translate3d(0, 0, 0); }
  50%  { transform: translate3d(-11%, -7%, 0); }
  100% { transform: translate3d(0, 0, 0); }
}
@keyframes breathe {
  0%   { opacity: 0.45; }
  50%  { opacity: 1; }
  100% { opacity: 0.45; }
}

/* ── Main ─────────────────────────────────────────────────────────────── */
main { flex: 1 0 auto; padding-block: clamp(2rem, 6vw, 4rem); }

.page-title {
  font: 700 clamp(1.9rem, 1.2rem + 3vw, 3.1rem)/1.08 var(--f-display);
  letter-spacing: -0.01em;
  max-width: 18ch;
}

.lead {
  margin-top: 1rem;
  max-width: 56ch;
  font-size: clamp(1rem, 0.95rem + 0.35vw, 1.2rem);
  color: var(--text-dim);
}

.eyebrow {
  font: 500 0.75rem/1 var(--f-mono);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 0.9rem;
}

.section { margin-top: clamp(2.5rem, 7vw, 4rem); }
.section-title {
  font: 600 clamp(1.15rem, 1rem + 0.7vw, 1.45rem)/1.25 var(--f-display);
  margin-bottom: 0.6rem;
}

.prose { max-width: 68ch; }
.prose h2 {
  font: 600 clamp(1.15rem, 1rem + 0.7vw, 1.45rem)/1.3 var(--f-display);
  margin-top: 2.25rem;
  margin-bottom: 0.6rem;
}
.prose h3 {
  font: 600 1.0625rem/1.35 var(--f-body);
  margin-top: 1.5rem;
  margin-bottom: 0.4rem;
}
.prose p, .prose li { color: var(--text-dim); }
.prose p + p { margin-top: 0.9rem; }
.prose ul { margin: 0.7rem 0 0 1.15rem; }
.prose li { margin-bottom: 0.45rem; }
.prose strong { color: var(--text); font-weight: 600; }
.prose a { color: var(--accent); }
.prose code {
  font: 400 0.9em/1.5 var(--f-mono);
  color: var(--text);
  background: var(--panel);
  border: 1px solid var(--panel-edge);
  border-radius: 3px;
  padding: 0.08em 0.35em;
  /* A storage key is long and has a dot in it. It wraps rather than pushing
     the document sideways — the page body never scrolls horizontally. */
  overflow-wrap: anywhere;
}
.prose a:hover { color: var(--text); }

.updated {
  font: 400 0.8125rem/1.5 var(--f-mono);
  color: var(--text-dim);
  margin-top: 0.5rem;
}

/* ── The games grid ────────────────────────────────────────────────────────
   Cards are STATIC MARKUP, generated into index.html by tools/gen-games.mjs
   from data/games.json between comment markers. Nothing here renders client
   side: a free game whose whole premise is a link somebody forwards has to be
   describable by a page that never ran a line of JavaScript, and a social
   crawler is exactly such a reader.

   `auto-fill` rather than `auto-fit`: with one game, auto-fit stretches that
   single card to the full width of the grid, which reads as a hero rather
   than as the first of several. */
.grid {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
  gap: clamp(1rem, 2.5vw, 1.5rem);
  margin-top: 1.75rem;
}

.card {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: clamp(1.1rem, 3vw, 1.5rem);
  background: var(--panel);
  border: 1px solid var(--panel-edge);
  border-radius: 6px;
}
.card-title { font: 600 1.125rem/1.3 var(--f-display); }
.card-title a { color: var(--text); text-decoration: none; }
.card-title a:hover { color: var(--accent); }
.card p { color: var(--text-dim); font-size: 0.9375rem; }
.card-meta {
  margin-top: auto;
  padding-top: 0.75rem;
  font: 400 0.75rem/1 var(--f-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* ── The empty state ───────────────────────────────────────────────────────
   The grid renders from data/games.json and that file is empty today, so this
   is what the site actually looks like. It is a full panel with real
   sentences in it rather than a greyed-out placeholder card, because a
   placeholder card is a promise of a thing that is not there, and a visitor
   who arrived from a link deserves to be told plainly what state the site is
   in and given somewhere to go.

   It is not the empty state's job to be exciting. It is its job to be honest,
   legible and not embarrassing. */
.empty {
  margin-top: 1.75rem;
  padding: clamp(1.5rem, 5vw, 2.5rem);
  background: var(--panel);
  border: 1px dashed var(--panel-edge-strong);
  border-radius: 6px;
  max-width: 44rem;
}
.empty-title {
  font: 600 clamp(1.15rem, 1rem + 0.7vw, 1.45rem)/1.3 var(--f-display);
  margin-bottom: 0.6rem;
}
.empty p { color: var(--text-dim); max-width: 56ch; }
.empty p + p { margin-top: 0.75rem; }

/* The one thing on the page spending the rationed hue. If a second element
   starts using --attention, this one stops being the thing you notice. */
.empty-status {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  margin-top: 1.25rem;
  padding: 0.5rem 0.85rem;
  border: 1px solid var(--attention);
  border-radius: 3px;
  font: 500 0.75rem/1 var(--f-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--attention);
}

/* ── Footer ───────────────────────────────────────────────────────────── */
.site-footer {
  border-top: 1px solid var(--panel-edge);
  padding-block: clamp(1.75rem, 4vw, 2.5rem);
  font-size: 0.875rem;
}
.footer-inner {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem clamp(1rem, 4vw, 2.5rem);
  align-items: center;
  justify-content: space-between;
}
.footer-note { color: var(--text-dim); }
.footer-note a { color: var(--text-dim); }
.footer-note a:hover { color: var(--text); }

/* ── The motion control ────────────────────────────────────────────────────
   The label says what pressing it will DO; aria-pressed says what state it is
   IN. Different questions, and one word cannot answer both.

   It carries `data-enhanced`, so the inline critical CSS hides it until the
   watchdog has armed the enhanced path. A control that visibly does nothing
   is the same false affordance as a hover glow on something unclickable, and
   with no JavaScript this button cannot do anything at all. */
.motion-toggle {
  appearance: none;
  cursor: pointer;
  background: var(--panel);
  color: var(--text-dim);
  border: 1px solid var(--panel-edge-strong);
  border-radius: 3px;
  padding: 0.45rem 0.8rem;
  font: 500 0.75rem/1 var(--f-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.motion-toggle:hover { color: var(--text); border-color: var(--accent); }

/* ── The error page ───────────────────────────────────────────────────── */
.error-card {
  max-width: 44rem;
  padding: clamp(1.5rem, 5vw, 2.5rem);
  background: var(--panel);
  border: 1px solid var(--panel-edge);
  border-radius: 6px;
}
.error-code {
  font: 500 0.75rem/1 var(--f-mono);
  letter-spacing: 0.2em;
  color: var(--accent);
  margin-bottom: 0.9rem;
}
.error-card p { color: var(--text-dim); margin-top: 0.75rem; max-width: 56ch; }
.error-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.5rem; }
.button {
  display: inline-block;
  padding: 0.6rem 1.1rem;
  border: 1px solid var(--accent);
  border-radius: 3px;
  color: var(--accent);
  background: var(--panel);
  text-decoration: none;
  font: 500 0.8125rem/1.2 var(--f-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.button:hover { color: var(--text); border-color: var(--text); }

/* The 404's decorative canvas. It is created by js/error-bg.js and never
   exists in the markup, so a page that never mounts it has no element to
   leave behind — which is also what makes the motion differential clean: a
   canvas that is REMOVED matches a canvas that was never created, and a
   canvas merely hidden while still holding its last frame matches nothing. */
.error-bg {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}

/* ── Dialogs ───────────────────────────────────────────────────────────────
   THERE IS NO DIALOG ON THIS SITE YET. This block is here so the first one
   inherits the two things the sibling repository paid for rather than
   rediscovering them.

   1. It is a native <dialog> opened with showModal(). The platform supplies
      the focus trap, aria-modal, Escape, focus return and inertness for
      everything behind it. The div-with-opacity-0 version kept CLOSED
      dialogs' controls in the tab order and in the accessibility tree.
   2. A dialog taller than the viewport is UNREACHABLE under plain centring:
      centring overflows equally at both ends, and the top goes where no
      scroll reaches. Measured at 320x256 on the sibling site, the panel top
      sat at -56px. `overflow: auto` plus `align-items: safe center`, behind
      an @supports guard because `safe` is the newer half of the pair. */
dialog {
  max-width: min(40rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 2rem);
  overflow: auto;
  background: var(--ground-lift);
  color: var(--text);
  border: 1px solid var(--panel-edge-strong);
  border-radius: 6px;
  padding: clamp(1.25rem, 4vw, 2rem);
}
@supports (align-items: safe center) {
  dialog {
    margin: auto;
    align-items: safe center;
  }
}

/* ── Motion ────────────────────────────────────────────────────────────────
   A STOPPED ANIMATION IS NOT STILLNESS. Everything below REMOVES motion;
   nothing pauses it. `animation-play-state: paused` parks an element wherever
   the clock happened to be, and so does a Web Animations .pause() and so does
   a canvas left holding its last frame. tools/checks/motion.mjs proves this
   by differential: the pose you reach by turning motion off mid-session has
   to be identical to the pose you reach by arriving with it already off, and
   a parked frame is a function of WHEN it stopped, so the two cannot match.

   Two blocks, and both have to exist:

   - the CLASS block is what a visitor with JavaScript gets, and it is the
     only one that can honour an explicit in-page choice;
   - the MEDIA block is what a browser running no JavaScript at all gets, and
     it is scoped `html:not(.motion-full)` because an unscoped media query
     beats the explicit choice and the override then works in one direction
     only. */
html.motion-reduced *,
html.motion-reduced *::before,
html.motion-reduced *::after {
  animation: none !important;
  transition: none !important;
}
html.motion-reduced .ambient { display: none; }

@media (prefers-reduced-motion: reduce) {
  html:not(.motion-full) *,
  html:not(.motion-full) *::before,
  html:not(.motion-full) *::after {
    animation: none !important;
    transition: none !important;
  }
  html:not(.motion-full) .ambient { display: none; }
}

/* ── forced-colors, the structural half ────────────────────────────────────
   The colour half is a token swap in tokens.css. What is left here is what a
   token cannot express: a border that was a hint at a hairline needs to be a
   line, and a decorative gradient that carried no information needs to stop
   painting rather than be re-coloured into something that looks like content.

   `forced-color-adjust: none` appears nowhere in this repository. Opting a
   surface out of forced colours opts the reader's own choice out, and nothing
   here is important enough for that. */
@media (forced-colors: active) {
  .card, .empty, .error-card, .skip, .button, .motion-toggle, dialog {
    border-width: 1px;
    border-style: solid;
  }
  .ambient { display: none; }
  .nav-list a { border-bottom-width: 1px; }
}
