/* =========================================================================
   massiveo's freaky website  -  shared stylesheet
   hand-coded, no build step. warm and light: cream, deep green, brown.

   the look leans on the personal-site web: patterned background, panels with
   a gradient title strip, small decorations, and things that react when you
   touch them. everything themeable lives in :root so it swaps in one place.
   ========================================================================= */

:root {
  /* --- the palette ---
     Four colours: deep green, cream, peach, brown. Everything below is one of
     those or a mix of them, so the whole page stays in one family.

     A four colour set cannot carry an interface on its own - it has no raised
     surface, no border, no quiet text tone - so the rest are mixes rather than
     new colours brought in from outside. Every text tone here clears 4.5:1
     against the surface it actually sits on. */

  /* --- base surfaces --- */
  --bg:         #F7EAE0;  /* cream, the page */
  --bg-2:       #F8DFCF;  /* cream toward peach, the background pattern */
  --panel:      #FBF6F1;  /* lifted off the page, so a box reads as a box */
  --panel-2:    #F8DAC7;  /* peach, the title strip */
  --border:     #D7AF99;  /* peach toward brown */
  --border-lit: #855948;  /* border on hover */
  --ink:        #1D4533;  /* deep green, main text (9.1:1 on the page) */
  --muted:      #876355;  /* brown toward cream, secondary text (4.5:1) */

  /* --- accents ---
     one primary and two supporting colours. the supporting pair are what
     carry meaning elsewhere: terracotta for "soon", rose for "gone". */
  --accent:     #1D4533;  /* deep green, the site's own colour */
  --accent-2:   #5E3122;  /* brown, for a second voice */
  --accent-3:   #A8502A;  /* terracotta. the peach itself is far too pale to
                             put text in, so the warm accent is the brown and
                             the peach pushed together and saturated. */
  --rose:       #8F3526;
  --green:      #367249;

  --link:       #1D4533;
  --link-hover: #5E3122;

  /* Tints that are not flat colours: the pools behind the page, the wash a
     row takes on hover, shadows, the ring around the live dot. Named so the
     dark theme can restate them, since a shadow that works on cream is
     invisible on near-black and a wash has to go the other way entirely. */
  --pool-1: rgba(29, 69, 51, .07);
  --pool-2: rgba(94, 49, 34, .06);
  --pool-3: rgba(168, 80, 42, .05);
  --shadow: rgba(94, 49, 34, .13);
  --shadow-strong: rgba(94, 49, 34, .22);
  --backdrop: rgba(94, 49, 34, .34);
  --hover-wash: rgba(29, 69, 51, .07);
  --card-wash: rgba(29, 69, 51, .13);
  --flash-wash: rgba(29, 69, 51, .13);
  --pulse-ring: rgba(54, 114, 73, .34);
  --pulse-ring-out: rgba(54, 114, 73, 0);

  /* Tells the browser which way round the page is, so its own furniture -
     scrollbars, form controls, the caret - matches instead of staying light. */
  color-scheme: light;


  /* --- fonts --- */
  --font-display: "Silkscreen", "Pixelify Sans", ui-monospace, monospace;
  --font-body:    "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono:    ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* --- layout ---
     the reading column stays narrow; pages with a grid opt into --maxw-wide,
     which is what stopped the celle cards being squeezed into 200px columns
     on a wide screen while being roomier on a narrow one. */
  --maxw: 640px;
  --maxw-wide: 1040px;

  /* --- motion ---
     Curves that approximate springs rather than fixed easing. --spring is
     critically damped: it settles without overshoot, which is what most of an
     interface wants. --spring-bounce overshoots slightly, for things that
     should feel like they have mass. --swift is for pointer-down feedback,
     which has to happen now rather than gracefully. */
  --spring:        cubic-bezier(.32, .72, 0, 1);
  --spring-bounce: cubic-bezier(.34, 1.56, .64, 1);
  --swift:         cubic-bezier(.4, 0, .2, 1);
  --dur:      .38s;   /* standard response, ~0.35s */
  --dur-fast: .16s;   /* pointer-down, has to feel connected */
  --ease: var(--spring);
}

/* -------------------------------------------------------------------------
   DARK

   The same four colours with their jobs swapped: the deep green stops being
   ink and becomes the ground, the cream stops being the page and becomes the
   text, and the peach takes over as the highlight. Nothing new is introduced.

   Stated twice on purpose. The media query is what someone gets before they
   have chosen anything, and :not([data-theme="light"]) lets an explicit
   choice of light override the system. The attribute block is the choice
   itself, and being later and more specific it wins either way.
   ------------------------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg:         #0D1F17;
    --bg-2:       #122B20;
    --panel:      #153225;
    --panel-2:    #1A3E2E;
    --border:     #2E5340;
    --border-lit: #B38A76;
    --ink:        #F7EAE0;
    --muted:      #9BA597;

    --accent:     #F9D2BA;
    --accent-2:   #B38A76;
    --accent-3:   #D9915C;
    --rose:       #E58C79;
    --green:      #7FC08F;

    --link:       #F9D2BA;
    --link-hover: #FFF1E6;

    --pool-1: rgba(249, 210, 186, .05);
    --pool-2: rgba(217, 145, 92, .045);
    --pool-3: rgba(127, 192, 143, .04);
    /* On near-black a brown shadow does nothing; depth has to come from black. */
    --shadow: rgba(0, 0, 0, .38);
    --shadow-strong: rgba(0, 0, 0, .5);
    --backdrop: rgba(4, 10, 7, .62);
    /* And a hover wash has to lighten rather than darken. */
    --hover-wash: rgba(249, 210, 186, .07);
    --card-wash: rgba(249, 210, 186, .09);
    --flash-wash: rgba(249, 210, 186, .12);
    --pulse-ring: rgba(127, 192, 143, .4);
    --pulse-ring-out: rgba(127, 192, 143, 0);

    color-scheme: dark;
  }
}

:root[data-theme="dark"] {
  --bg:         #0D1F17;
  --bg-2:       #122B20;
  --panel:      #153225;
  --panel-2:    #1A3E2E;
  --border:     #2E5340;
  --border-lit: #B38A76;
  --ink:        #F7EAE0;
  --muted:      #9BA597;

  --accent:     #F9D2BA;
  --accent-2:   #B38A76;
  --accent-3:   #D9915C;
  --rose:       #E58C79;
  --green:      #7FC08F;

  --link:       #F9D2BA;
  --link-hover: #FFF1E6;

  --pool-1: rgba(249, 210, 186, .05);
  --pool-2: rgba(217, 145, 92, .045);
  --pool-3: rgba(127, 192, 143, .04);
  /* On near-black a brown shadow does nothing; depth has to come from black. */
  --shadow: rgba(0, 0, 0, .38);
  --shadow-strong: rgba(0, 0, 0, .5);
  --backdrop: rgba(4, 10, 7, .62);
  /* And a hover wash has to lighten rather than darken. */
  --hover-wash: rgba(249, 210, 186, .07);
  --card-wash: rgba(249, 210, 186, .09);
  --flash-wash: rgba(249, 210, 186, .12);
  --pulse-ring: rgba(127, 192, 143, .4);
  --pulse-ring-out: rgba(127, 192, 143, 0);

  color-scheme: dark;
}

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

body {
  margin: 0;
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.6;
  /* Body tracking sits at zero; only large type gets pulled in. */
  letter-spacing: 0;
  /* Resistance at the ends of the page rather than a hard stop. */
  overscroll-behavior-y: contain;
  -webkit-font-smoothing: antialiased;

  /* A quiet dot grid over two soft pools of colour. Pure CSS, so there is no
     image to load and it scales to any screen. */
  background-color: var(--bg);
  /* Only the dot grid lives here now. The three pools of colour moved to
     their own layer below, so they can move without the grid moving with
     them. */
  background-image: radial-gradient(var(--bg-2) 1px, transparent 1px);
  background-size: 22px 22px;
}

/* The pools of colour, on a layer of their own so they can drift.

   Oversized and centred, because a layer that exactly covered the screen
   would show its own edges the moment it moved. Fixed and behind everything,
   and it takes no pointer events, so it cannot get in the way of anything.

   Transform only, which the compositor can do on its own without touching
   layout or paint. Slow enough that you would not catch it moving if you
   watched for it: a minute and a half for one pass. */
body::before {
  content: "";
  position: fixed;
  inset: -25%;
  z-index: -1;
  pointer-events: none;
  background-image:
    radial-gradient(circle at 22% 18%, var(--pool-1), transparent 42%),
    radial-gradient(circle at 78% 14%, var(--pool-2), transparent 38%),
    radial-gradient(circle at 50% 86%, var(--pool-3), transparent 46%);
  animation: drift 90s ease-in-out infinite alternate;
}
@keyframes drift {
  from { transform: translate3d(-1.5%, -1%, 0) scale(1); }
  to   { transform: translate3d(1.5%, 1.5%, 0) scale(1.06); }
}

img { max-width: 100%; display: block; image-rendering: pixelated; }

a {
  color: var(--link);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color .18s var(--ease), border-color .18s var(--ease);
}
a:hover, a:focus { color: var(--link-hover); border-bottom-color: currentColor; }

a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-bottom-color: transparent;
}

/* -------------------------------------------------------------------------
   PAGE SHELL
   ------------------------------------------------------------------------- */
.wrap {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 64px 18px 48px;
}
/* Opt-in on pages that hold a grid rather than prose. */
.wrap--wide { max-width: var(--maxw-wide); }

/* -------------------------------------------------------------------------
   HEADER / WORDMARK + NAV
   ------------------------------------------------------------------------- */
.site-header { text-align: center; margin-bottom: 36px; }

.wordmark {
  font-family: var(--font-display);
  /* Sized for the name it carries. This one is a phrase rather than a single
     word, so it needs to come down a long way from what one word could take
     before it runs off a phone. */
  font-size: clamp(15px, 4.4vw, 30px);
  font-weight: 400;
  /* Large type wants negative tracking; the display face is already wide, so
     this pulls it back to something that reads as one line. */
  letter-spacing: .02em;
  /* Rather than overflow if it ever does not fit, break where it reads best. */
  text-wrap: balance;
  margin: 0;
  /* The three accents drift across the letters, slowly enough to read as a
     sheen rather than a rainbow. */
  background: linear-gradient(100deg, var(--ink) 12%, var(--accent) 34%, var(--accent-2) 52%, var(--accent-3) 68%, var(--ink) 90%);
  background-size: 260% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: wordmark-sheen 14s linear infinite;
}
@keyframes wordmark-sheen {
  from { background-position: 0% 0; }
  to   { background-position: 260% 0; }
}

.nav {
  margin-top: 16px;
  font-size: 15px;
  color: var(--muted);
}
.nav a {
  position: relative;
  display: inline-block;
  color: var(--muted);
  border-bottom: none;
  padding: 2px 3px;
  transition: color var(--dur-fast) var(--swift), transform var(--dur) var(--spring);
}
/* An underline that grows from the middle. Driven by scaleX rather than by
   animating left and right, which would relayout the pseudo-element on every
   frame; a transform stays on the compositor. */
.nav a::after {
  content: "";
  position: absolute;
  left: 2px; right: 2px; bottom: -2px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: 50% 50%;
  transition: transform var(--dur) var(--spring);
}
.nav a:hover::after, .nav a:focus::after { transform: scaleX(1); }
.nav a:hover, .nav a:focus { color: var(--link-hover); }
/* Pointer-down, not release: the response happens as the finger lands. */
.nav a:active { transform: scale(.96); transition-duration: var(--dur-fast); }
.nav a.active { color: var(--accent); }
.nav a.active::after { transform: scaleX(1); background: var(--accent); opacity: .5; }
.nav .sep { color: var(--border); margin: 0 6px; }

/* -------------------------------------------------------------------------
   BOX — a panel with a gradient title strip
   ------------------------------------------------------------------------- */
.box {
  /* Slightly translucent over the patterned background, so the panel reads as
     a layer above the page rather than a hole cut in it. */
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  backdrop-filter: blur(8px) saturate(120%);
  -webkit-backdrop-filter: blur(8px) saturate(120%);
  border: 1px solid var(--border);
  border-radius: 10px;
  margin-bottom: 26px;
  overflow: hidden;
  transition:
    border-color var(--dur) var(--swift),
    transform var(--dur) var(--spring),
    box-shadow var(--dur) var(--swift);
}
/* A browser without color-mix still gets a solid panel rather than nothing. */
@supports not (background: color-mix(in srgb, red 50%, transparent)) {
  .box { background: var(--panel); }
}
.box:hover {
  border-color: var(--border-lit);
  box-shadow: 0 6px 22px var(--shadow);
}

.box__title {
  position: relative;
  font-family: var(--font-display);
  font-size: 13px;
  letter-spacing: 1px;
  color: var(--accent);
  padding: 10px 16px;
  border-bottom: 1px solid var(--border);
  background: linear-gradient(90deg, var(--panel-2), var(--panel) 72%);
}
/* A thin coloured rule along the top of the strip, so a box reads as titled
   even before the text does. */
.box__title::before {
  content: "";
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 2px;
  background: linear-gradient(90deg, var(--accent), var(--accent-2) 55%, transparent);
  opacity: .8;
}
.box__body { padding: 16px 18px; }
.box__body p { margin: 0 0 .9em 0; }
.box__body p:last-child { margin-bottom: 0; }

/* Per-box accent, so pages are not all one colour. */
.box--violet .box__title { color: var(--accent-2); }
.box--violet .box__title::before { background: linear-gradient(90deg, var(--accent-2), var(--accent-3) 55%, transparent); }
.box--amber .box__title { color: var(--accent-3); }
.box--amber .box__title::before { background: linear-gradient(90deg, var(--accent-3), var(--rose) 55%, transparent); }

/* -------------------------------------------------------------------------
   LINK LIST
   ------------------------------------------------------------------------- */
.links { list-style: none; margin: 0; padding: 0; }
.links li {
  padding: 9px 4px;
  border-bottom: 1px solid var(--border);
  border-radius: 4px;
  /* The nudge is a transform. Animating padding would relayout the row, and
     everything below it, sixty times a second. */
  transform: translateZ(0);
  transition: background var(--dur) var(--swift), transform var(--dur) var(--spring);
}
.links li:last-child { border-bottom: none; }
.links li:hover { background: var(--hover-wash); transform: translateX(6px); }
.links li:active { transform: translateX(6px) scale(.995); transition-duration: var(--dur-fast); }
.links a { font-size: 16px; }
.links .label { color: var(--muted); font-size: 14px; margin-left: 8px; }

.section-label {
  font-family: var(--font-display);
  font-size: 13px;
  letter-spacing: 1px;
  color: var(--muted);
  margin: 0 0 8px 2px;
}

/* -------------------------------------------------------------------------
   ENTRANCE — things arrive as they come into view

   Driven by a class the observer adds, not by a keyframe that fires on load.
   A keyframe cannot be interrupted or reversed; a transition on transform and
   opacity animates from wherever the element currently is, so a reveal caught
   halfway simply carries on from there.
   ------------------------------------------------------------------------- */
.rise {
  opacity: 0;
  transform: translate3d(0, 14px, 0);
  transition:
    opacity var(--dur) var(--swift),
    transform var(--dur) var(--spring);
  transition-delay: var(--rise-delay, 0ms);
}
.rise.is-in { opacity: 1; transform: translate3d(0, 0, 0); }

/* If the script never runs, nothing should be stuck invisible. */
.no-js .rise, html:not(.js) .rise { opacity: 1; transform: none; }

/* -------------------------------------------------------------------------
   FOOTER
   ------------------------------------------------------------------------- */
.site-footer {
  margin-top: 48px;
  text-align: center;
  color: var(--muted);
  font-size: 13px;
}
.site-footer a { color: var(--muted); }
.site-footer a:hover { color: var(--link-hover); }
.site-footer .sprout { color: var(--green); }

/* -------------------------------------------------------------------------
   CORNER SPRIGS — drawn, not loaded, and purely decorative
   ------------------------------------------------------------------------- */
.sprig {
  position: fixed;
  width: 120px; height: 120px;
  pointer-events: none;
  opacity: .5;
  z-index: -1;
}
.sprig--tl { top: -10px; left: -10px; }
.sprig--br { bottom: -10px; right: -10px; transform: rotate(180deg); }
/* On a narrow screen they would sit on top of the content, so they go. */
@media (max-width: 700px) { .sprig { display: none; } }

@media (max-width: 480px) {
  .wrap { padding: 44px 18px 40px; }
}

/* Everything above is decoration. None of it should move for someone who has
   asked their system for less of it. */
/* Someone who has asked for less motion still gets the interface, just
   without the movement: reveals become a plain cross-fade rather than a
   slide, and the decorative loops stop entirely. */
@media (prefers-reduced-motion: reduce) {
  .wordmark { animation: none; }
  .box:hover, .links li:hover, .nav a:active { transform: none; }

  .rise { transform: none; transition: opacity .2s linear; transition-delay: 0ms; }
  .rise.is-in { transform: none; }

  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
  /* The cross-fade is the one bit of motion worth keeping, so it is restored
     after the blanket rule above. */
  .rise { transition-duration: .2s !important; }
}

/* -------------------------------------------------------------------------
   PRESS — the response to a pointer landing

   Scale only, so it stays on the compositor, and short enough to read as
   contact rather than as an animation.
   ------------------------------------------------------------------------- */
.is-pressed {
  transform: scale(.985) !important;
  transition-duration: var(--dur-fast) !important;
}
.links li.is-pressed { transform: translateX(6px) scale(.99) !important; }

@media (prefers-reduced-motion: reduce) {
  .is-pressed { transform: none !important; }
}

/* -------------------------------------------------------------------------
   HAND-OFF FROM THE SPINNER

   The result used to replace the spinner in one frame: the loader vanished
   and a full list was simply there. Nothing signalled that the wait had
   ended, so it read as a jump rather than an arrival. The spinner now steps
   back and the content comes forward, which is short enough not to add to
   the wait but long enough to be seen.
   ------------------------------------------------------------------------- */
.loading {
  transition:
    opacity var(--dur-fast) var(--swift),
    transform var(--dur-fast) var(--swift);
}
/* Sinks slightly as it leaves, so it reads as making way rather than moving
   with the content that is rising into its place. */
.loading.is-done {
  opacity: 0;
  transform: translate3d(0, 6px, 0) scale(.96);
  pointer-events: none;
}

/* What the spinner was waiting for. Starts a touch small and low, settles on
   the spring curve so it overshoots a hair before landing. */
.pop {
  opacity: 0;
  transform: translate3d(0, 10px, 0) scale(.985);
  transition:
    opacity var(--dur) var(--swift),
    transform var(--dur) var(--spring);
  transition-delay: var(--pop-delay, 0ms);
}
.pop.is-in {
  opacity: 1;
  transform: translate3d(0, 0, 0) scale(1);
}

/* Never leave content invisible if the script cannot finish the job. */
.no-js .pop, html:not(.js) .pop { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .loading, .pop { transition: none; }
  .loading.is-done { opacity: 0; transform: none; }
  .pop { opacity: 1; transform: none; }
}

/* -------------------------------------------------------------------------
   3D SKIN VIEWER
   Six boxes made of divs. The compositor does the work, so there is no
   canvas, no WebGL context and nothing to load.
   ------------------------------------------------------------------------- */
.sk-stage {
  width: 100%;
  height: 240px;
  display: grid;
  place-items: center;
  perspective: 620px;
  cursor: grab;
  /* A horizontal drag should turn the model, not scroll the page under it. */
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}
.sk-stage.is-dragging { cursor: grabbing; }

.sk-player {
  transform-style: preserve-3d;
  /* Only while it is not being dragged; during a drag the transform is set
     every pointermove and a transition would lag behind the finger. */
  transition: transform var(--dur) var(--swift);
}
.sk-stage.is-dragging .sk-player { transition: none; }

.sk-box {
  position: absolute;
  /* The player element has no size of its own, so this is simply its centre.
     Each box then pulls itself back by half its own width and height. */
  left: 50%;
  top: 50%;
  transform-style: preserve-3d;
}
.sk-face {
  position: absolute;
  left: 50%;
  top: 50%;
  /* A skin is 64 pixels across. Smoothing it turns a face into porridge. */
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  backface-visibility: hidden;
}
/* The hat and the other second-layer parts sit directly over what they cover,
   so without this the two z-fight and the face flickers through its own hair
   as the model turns. */
.sk-hat .sk-face,
.sk-over .sk-face { backface-visibility: visible; }

.skin-mount {
  min-height: 240px;
  display: grid;
  place-items: center;
  color: var(--muted);
  font-size: 13px;
}

/* A skin comes off another host, so there is always a moment of nothing.
   Same ring as the page spinner, at the size the model will occupy. */
.sk-spinner {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 2.5px solid var(--border);
  border-top-color: var(--accent);
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* The model fades up once its atlas is actually decoded, rather than
   assembling itself visibly a face at a time. */
.sk-stage { opacity: 0; transition: opacity var(--dur) var(--swift); }
.skin-mount.is-loaded .sk-stage { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .sk-spinner { animation-duration: 2s; }
  .sk-stage { transition: none; opacity: 1; }
}

/* A mark before each link. Inline SVG taking currentColor, so there is
   nothing to fetch and each one is already the right colour, including on
   hover. */
.links a { display: inline-flex; align-items: center; gap: 8px; }
.ico {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
  /* Optical rather than baseline alignment: a 15px glyph next to 16px text
     sits a shade high otherwise. */
  transform: translateY(.5px);
}

/* -------------------------------------------------------------------------
   SMALL THINGS
   ------------------------------------------------------------------------- */

/* Selecting text should look like it belongs to the page rather than to the
   browser. */
::selection {
  background: var(--panel-2);
  color: var(--accent-2);
}

/* Whether the data behind the page is actually arriving.

   Not decoration: it reads the age of the newest sighting. Breathing means
   something reported recently, still and amber means the feed has gone quiet,
   and it says which in words on hover for anyone who cannot see the colour. */
.pulse {
  display: inline-block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  margin-left: 8px;
  vertical-align: middle;
  background: var(--muted);
  /* Visible at rest. The animation only varies it, so a browser that never
     runs the animation still shows the dot. */
  opacity: 1;
}
.pulse.is-live {
  background: var(--green);
  animation: pulse-breathe 2.6s ease-in-out infinite;
}
.pulse.is-quiet { background: var(--accent-3); }
@keyframes pulse-breathe {
  0%, 100% { opacity: 1;   box-shadow: 0 0 0 0 var(--pulse-ring); }
  50%      { opacity: .55; box-shadow: 0 0 0 5px var(--pulse-ring-out); }
}

@media (prefers-reduced-motion: reduce) {
  body::before { animation: none; }
  .pulse.is-live { animation: none; }
}

/* -------------------------------------------------------------------------
   THEME TOGGLE
   ------------------------------------------------------------------------- */
.theme-toggle {
  position: fixed;
  left: 18px;
  bottom: 18px;
  z-index: 40;
  width: 42px;
  height: 42px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--accent);
  cursor: pointer;
  box-shadow: 0 4px 14px var(--shadow);
  transition:
    border-color var(--dur) var(--swift),
    transform var(--dur) var(--spring),
    box-shadow var(--dur) var(--swift);
}
.theme-toggle:hover { border-color: var(--border-lit); transform: translateY(-2px); }
.theme-toggle:active { transform: translateY(-2px) scale(.94); transition-duration: var(--dur-fast); }

/* One button, two marks stacked on top of each other. Whichever one is not
   the current theme is the one you can switch to, so that is the one shown. */
.theme-toggle .ico { position: absolute; width: 18px; height: 18px; transition:
    opacity var(--dur) var(--swift), transform var(--dur) var(--spring); }
.theme-toggle .ico--moon { opacity: 1; transform: rotate(0) scale(1); }
.theme-toggle .ico--sun  { opacity: 0; transform: rotate(-70deg) scale(.5); }
:root[data-theme="dark"] .theme-toggle .ico--moon { opacity: 0; transform: rotate(70deg) scale(.5); }
:root[data-theme="dark"] .theme-toggle .ico--sun  { opacity: 1; transform: rotate(0) scale(1); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .ico--moon { opacity: 0; transform: rotate(70deg) scale(.5); }
  :root:not([data-theme="light"]) .theme-toggle .ico--sun  { opacity: 1; transform: rotate(0) scale(1); }
}

/* The circle that wipes one theme over the other.

   A view transition snapshots the old page and the new one; the old is left
   alone and the new is revealed through a growing circle. Only the incoming
   layer is animated, and it sits on top, which is why the effect looks like
   the new theme spilling over the old rather than the page fading. */
::view-transition-old(root) { animation: none; }
::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
:root.theme-sweeping::view-transition-new(root) {
  animation: theme-circle .62s var(--swift) both;
}
:root.theme-sweeping::view-transition-old(root) { z-index: 0; }
:root.theme-sweeping::view-transition-new(root) { z-index: 1; }
@keyframes theme-circle {
  from { clip-path: circle(0% at var(--sweep-x, 50%) var(--sweep-y, 50%)); }
  to   { clip-path: circle(150% at var(--sweep-x, 50%) var(--sweep-y, 50%)); }
}

@media (prefers-reduced-motion: reduce) {
  .theme-toggle .ico { transition: none; }
  :root.theme-sweeping::view-transition-new(root) { animation: none; }
}


/* -------------------------------------------------------------------------
   DETAIL SHEET

   The panel that opens over a celle or a user. Shared, because both pages
   want the same thing: a <dialog>, so the browser owns the backdrop, the
   focus trap and Escape rather than any of it being written again here.
   ------------------------------------------------------------------------- */
@keyframes sheet-in {
  from { opacity: 0; transform: translateY(10px) scale(.97); }
  to   { opacity: 1; transform: none; }
}
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }

.sheet[open] { animation: sheet-in .32s var(--spring-bounce) both; }
.sheet[open]::backdrop { animation: backdrop-in .25s var(--swift) both; }

.sheet {
  width: min(620px, calc(100vw - 32px));
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--panel);
  color: var(--ink);
  box-shadow: 0 18px 50px var(--shadow-strong);
}
.sheet::backdrop {
  background: var(--backdrop);
  backdrop-filter: blur(3px);
}
.sheet__head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px;
  padding: 12px 14px;
  background: var(--panel-2);
  border-bottom: 1px solid var(--border);
}
/* The display face is a 5x7 pixel font. Lovely as decoration, but a celle
   id is a string you actually have to read character by character, and
   B351 in it is a guessing game. Mono, larger, with the tracking opened
   up so the digits separate. */
.sheet__title { margin: 0; }
/* Both of these are buttons that look like the text they replaced: the
   only hint is the pointer and what happens when you press. */
.sheet__id, .sheet__owner {
  font: inherit;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  transition: color var(--dur-fast) var(--swift), opacity var(--dur-fast) var(--swift);
}
.sheet__id {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 19px;
  font-weight: 600;
  letter-spacing: .06em;
  color: var(--accent-2);
}
.sheet__owner {
  font-family: var(--font-body);
  font-size: 12px;
  color: var(--muted);
}
.sheet__id:hover, .sheet__owner:hover { color: var(--accent); }
.sheet__id:active, .sheet__owner:active { opacity: .6; }
.sheet__id.is-copied, .sheet__owner.is-copied { color: var(--green); }
.sheet__id.is-copyfail, .sheet__owner.is-copyfail { color: var(--rose); }
.sheet__close {
  font: inherit; font-size: 22px; line-height: 1;
  background: none; border: none; color: var(--muted);
  cursor: pointer; padding: 2px 6px; border-radius: 6px;
  transition: color var(--dur-fast) var(--swift);
}
.sheet__close:hover { color: var(--accent-2); }

.sheet__body { padding: 4px 14px 16px; }
@media (min-width: 520px) {
  .sheet__body { display: grid; grid-template-columns: 210px 1fr; gap: 16px; align-items: start; }
}
.sheet__model { min-width: 0; }
.sheet__hint {
  margin: 2px 0 0; text-align: center;
  color: var(--muted); font-size: 12px;
}

.sheet__facts { margin: 10px 0 0; }
.sheet__facts div {
  display: flex; justify-content: space-between; gap: 12px;
  padding: 8px 0;
  border-bottom: 1px dashed var(--border);
}
.sheet__facts div:last-child { border-bottom: none; }
/* Each row a beat after the one above it. Short enough that the whole
   list has landed before you have finished reading the first line. */
@keyframes fact-in {
  from { opacity: 0; transform: translateX(6px); }
  to   { opacity: 1; transform: none; }
}
.sheet__facts div { animation: fact-in .3s var(--swift) both; }
.sheet__facts dt {
  font-family: var(--font-display); font-size: 10px; letter-spacing: 1px;
  color: var(--muted); text-transform: uppercase;
  flex: 0 0 auto;
}
.sheet__facts dd {
  margin: 0; text-align: right;
  font-family: var(--font-mono); font-size: 13px;
  overflow-wrap: anywhere;
}
.sheet__facts dd.is-unknown { color: var(--muted); font-style: italic; }


@media (prefers-reduced-motion: reduce) {
  .sheet[open], .sheet[open]::backdrop, .sheet__facts div { animation: none; }
}

/* The celler credited to a user, inside their panel. */
.sheet__side { min-width: 0; }
.owned { margin-top: 14px; }
.owned__head {
  margin: 0 0 8px;
  font-family: var(--font-display);
  font-size: 10px;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--muted);
}
.owned__list {
  list-style: none;
  margin: 0;
  padding: 0;
  /* Someone can hold a lot of these, so the list scrolls inside the panel
     rather than growing it past the screen. */
  max-height: 190px;
  overflow-y: auto;
}
.owned__list li {
  padding: 5px 0;
  border-bottom: 1px dashed var(--border);
  font-family: var(--font-mono);
  font-size: 13px;
}
.owned__list li:last-child { border-bottom: none; }

/* The line itself: arrow, id, then the countdown pushed to the right. */
.owned__row {
  display: flex;
  align-items: center;
  gap: 8px;
}
.owned__row b { color: var(--ink); font-weight: 600; }
.owned__row span { color: var(--muted); margin-left: auto; }
.owned__row span.is-free { color: var(--accent-3); }

/* Same size and weight as the one on a celle card, since it does the same
   thing and should be recognised as the same control. */
.owned__more {
  flex: 0 0 auto;
  width: 20px; height: 20px;
  display: grid; place-items: center;
  background: none; border: none; padding: 0;
  color: var(--muted);
  cursor: pointer;
  border-radius: 6px;
  transition: color var(--dur-fast) var(--swift),
              transform var(--dur) var(--spring);
}
.owned__more svg { width: 12px; height: 12px; }
.owned__more:hover { color: var(--accent-3); }
.owned__more:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.owned__list li.is-open .owned__more { transform: rotate(180deg); color: var(--accent-3); }

/* What it opens, indented under the row it belongs to.

   It grows into place rather than appearing at full height. grid-template-rows
   from 0fr to 1fr is the one way to animate to a height nobody has measured:
   the row is told to take none of the space and then all of it, and the
   browser works out what that is. A plain height transition cannot, because
   there is no number to transition to. */
.owned__extra {
  display: grid;
  grid-template-rows: 0fr;
  margin: 0 0 0 28px;
  padding-left: 10px;
  border-left: 1px solid var(--border);
  opacity: 0;
  transition:
    grid-template-rows var(--dur) var(--spring),
    opacity var(--dur-fast) var(--swift),
    margin var(--dur) var(--spring);
}
.owned__extra > * { min-height: 0; }
.owned__list li.is-open .owned__extra {
  grid-template-rows: 1fr;
  opacity: 1;
  margin: 4px 0 6px 28px;
}
/* hidden would take it out of the layout and there would be nothing to grow,
   so it is folded shut instead and only removed from the reading order. */
.owned__extra[hidden] { display: grid; }
.owned__extra__inner { overflow: hidden; }
/* Direct children only. As a descendant selector this also caught the wrapper
   itself, which turned it into a flex row and laid every line out side by side
   as a narrow column of single letters. */

/* Set like the panel's own rows, not smaller. It is the same information in
   the same panel, so shrinking it only made it look like a footnote to the
   celle rather than the record of it. */
.owned__extra__inner > div {
  display: flex; justify-content: space-between; gap: 12px;
  padding: 8px 0;
  border-bottom: 1px dashed var(--border);
}
.owned__extra__inner > div:last-child { border-bottom: none; }
.owned__extra dt {
  color: var(--muted);
  font-family: var(--font-display);
  font-size: 10px; letter-spacing: 1px; text-transform: uppercase;
  flex: 0 0 auto;
}
.owned__extra dd {
  margin: 0; text-align: right;
  font-family: var(--font-mono); font-size: 13px;
  color: var(--ink);
  overflow-wrap: anywhere;
}
.owned__extra dd.is-unknown { color: var(--muted); font-style: italic; }

@media (prefers-reduced-motion: reduce) {
  .owned__extra, .owned__more { transition: none; }
}

/* The cards say they can be pressed. */
.user { cursor: pointer; }
.user:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
