/* Landing page.
   Deliberately not the warm paper of the rest of thomasddewitt.com: the page
   is a window onto a sky, and the surrounding chrome should get out of its
   way. The viewer itself is a lights-out overlay and lives in viewer.css.

   Careful with comments here: a stray asterisk-slash (as in a glob like
   thought-cloud slash star slash index.html) closes the comment early, and
   the parser then discards rules until it can resync — which silently took
   the whole :root block with it once, leaving every custom property
   undefined and the loading overlay transparent. */

:root {
  --ink: #eaf0f6;
  --dim: #c3cedb;
  --faint: #93a2b1;
  /* The backdrop is a photograph, not a flat colour, and it may be bright
     sky or dark ocean from one demo to the next. Small type gets its own
     shadow rather than relying on the scrim, which has to stay light enough
     to see the image through. */
  --shadow-text: 0 1px 3px rgba(3, 8, 14, 0.85), 0 0 14px rgba(3, 8, 14, 0.5);
  /* Was #f2a25c. Now exactly viewer.css's --hot: the landing page and the
     pause menu are one product and had two different oranges. */
  --amber: #e8834a;
  --void: #05080c;
  --line: rgba(234, 240, 246, 0.18);
  --line-soft: rgba(234, 240, 246, 0.10);
  /* The only other hue on the page, and it is deliberately not the amber.
     Amber already means "this is the thing you are pointing at" — it is the
     hover border, the group rule, the primary button and the click cursor —
     so a caution set in amber would be indistinguishable from a row being
     active. This red was already here as #capability.bad; it is now a token
     so the page has one red rather than two. */
  --alarm: #ef7a7a;

  /* --- borrowed from viewer.css, and the reason this page was redesigned ---
     The cards here used to be rgba(8,13,20,0.42) behind a 14px backdrop
     blur. A backdrop filter blurs what is behind a panel without darkening
     it, so the type sat on the mean of whatever the reel was showing — and
     0.42 over a sunlit cloud top measures 1.1:1 where 4.5:1 is the floor.
     The viewer settled that argument on 2026-08-14 with eight skins measured
     against a sunlit cloud and a night ocean; this page now uses the answer
     rather than a second opinion. Opaque, square, unblurred. */
  --panel-bg: rgba(6, 9, 13, 0.94);
  --panel-line: rgba(150, 172, 200, 0.22);
  --panel-radius: 2px;
  --ink-bright: #f0f5fb;

  --ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
  --transition-fast: 0.15s ease;

  /* --- the pointer ---------------------------------------------------------
     A paper dart with its nose on the hotspot: the flyer the app now opens
     with, as the cursor. Two states differing only in fill — paper white by
     default, amber when there is something to click.

     Defined here rather than in viewer.css because this file is the one with
     a global :root and index.html always loads it first; viewer.css uses
     these same two tokens and does not redefine them.

     Both states are drawn dark-under-light, a dark outline around a light
     body, because the pointer is the one piece of chrome that can never have
     an opaque background of its own — it crosses a sunlit cloud top and a
     night ocean in the same gesture, which is the contrast case the panel
     skins were measured against.

     WHY THE CLICK STATE MAY BE SATURATED WHEN THE REST OF THE CHROME MAY NOT.
     An earlier version made the DEFAULT cursor amber, and it clashed with the
     imagery constantly, because the default cursor is over cloud most of the
     time. The click state is the opposite: it appears only on the opaque
     panels, cards and buttons, since that is where the clickable things are,
     and the render itself deliberately keeps the white dart. Thomas,
     2026-08-14: "the hover over is good because it's only active when over
     black boxes. when over clouds, it's white which is good." Keep that
     property when adding controls — an amber pointer over the sky is the
     thing this arrangement exists to avoid.

     Derived from the 4-triangle plus on thomasddewitt.com, which shares the
     two-state idea; the artwork differs because soar is not warm paper. */
  --cursor-dart: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path d='M2 2 L21.5 11 L12 12 L10.5 21.5 Z' fill='%23eef2f7' stroke='%23111820' stroke-width='1.1' stroke-linejoin='round'/><path d='M2 2 L12 12' stroke='%23111820' stroke-width='0.9' fill='none'/></svg>") 2 2, crosshair;
  --cursor-dart-click: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path d='M2 2 L21.5 11 L12 12 L10.5 21.5 Z' fill='%23e8834a' stroke='%23111820' stroke-width='1.1' stroke-linejoin='round'/><path d='M2 2 L12 12' stroke='%23111820' stroke-width='0.9' fill='none'/></svg>") 2 2, pointer;
}

* { box-sizing: border-box; }

html, body {
  height: 100%;
  margin: 0;
  background: var(--void);
  color: var(--ink);
  font-family: var(--mono);
  overflow: hidden;
  cursor: var(--cursor-dart);
}
/* The breadcrumb links deliberately do NOT take the amber pointer, even
   though they are links. They are the one clickable thing on the page with
   no opaque surface under them — they sit straight on the sky — and the rule
   above is that amber never goes over cloud. They are not left without an
   affordance: `.strip a:hover` already lifts them from --faint to --ink.

   Stated rather than simply omitted. An anchor's UA default is `pointer`, so
   saying nothing here does not inherit the dart from `body` — it hands the
   browser's own hand cursor to the one element on the page most likely to be
   hovered first. */
.strip a { cursor: var(--cursor-dart); }

/* --- the reel: whichever demo is being previewed --- */

#reel {
  position: fixed; inset: 0; overflow: hidden;
  filter: saturate(0.92) contrast(1.04);
}
/* A still rather than a loop. A 60 s 1440p60 video ran 30-110 MB per demo —
   for the FIF cascade, fifteen times the weight of the field it advertises —
   against 60-210 kB for a 4K still.

   And a still that actually holds still: this carried a 110 s scale-and-pan
   drift to buy back a sense of movement, which is exactly what a converged
   render of a cloud field should not do (Thomas, 2026-08-15). The only motion
   left on the page is the cross-fade between one case and the next. */
#reel img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.9s ease;
}
#reel img.on { opacity: 1; }
/* Cyberpunk's backdrop sits OVER the reel rather than in it: the mode has one
   picture of itself, not a case per row, and the rail is not on the page to
   hover anyway. Over rather than instead of, so nothing about the reel's own
   state has to be unwound — leaving the mode uncovers whatever it was
   showing, mid-fade included. The cross-fade is the reel's, on purpose: the
   two directions of a mode switch should look the same. */
#reel-cyberpunk { z-index: 1; }
body.mode-cyberpunk #reel-cyberpunk { opacity: 1; }
#reel::after {
  content: '';
  position: absolute; inset: 0;
  /* Lighter than it was on every stop. The scrim exists to keep small type
     legible, but the type that most needed it (the wordmark, the group heads)
     now carries its own halo — so the picture can come forward and the
     vignette can stop announcing itself. Roughly two thirds of the old
     strength at the corners and a little over half at the edges. */
  background:
    radial-gradient(120% 90% at 50% 38%, transparent 26%,
                    rgba(5, 8, 12, 0.35) 70%, rgba(5, 8, 12, 0.62) 100%),
    linear-gradient(to bottom, rgba(5, 8, 12, 0.42) 0%, transparent 20%,
                    transparent 46%, rgba(5, 8, 12, 0.60) 92%);
  transition: opacity 0.6s ease;
}
/* Lighter, not heavier: the chrome has faded to 0.2, so the scrim no
   longer has any text to protect and can get out of the image's way. */
body.previewing #reel::after { opacity: 0.45; }
#reel::before {
  content: '';
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
  opacity: 0.055;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* --- frame ---------------------------------------------------------------
   Two columns: the cases down the left, the wordmark and the one primary
   control centred in what is left. The sky keeps the middle of the frame,
   and hovering a case drops everything but that case so the still it is
   previewing becomes most of the screen.

   The height chain has to be unbroken from <html> down or `height: 100%`
   here resolves against an auto-height parent and collapses to content
   height, which puts the bottom readout in the middle of the screen. */

#landing { height: 100%; }

.frame {
  position: relative; z-index: 3;
  height: 100%;
  display: grid;
  /* `auto`, sized by .cases's own width, so the column can be animated —
     see "basic vs research" below. It was minmax(18rem, 21rem); .cases now
     carries the 21rem itself and the max-width keeps it off narrow screens. */
  grid-template-columns: auto 1fr;
  grid-template-rows: auto 1fr auto;
  column-gap: 3rem;
  padding: 1.6rem 2rem 1.5rem;
}
.frame::before, .frame::after {
  content: '';
  position: absolute;
  width: 44px; height: 44px;
  border: 1px solid var(--line);
  pointer-events: none;
}
.frame::before { top: 1.1rem; left: 1.2rem; border-right: 0; border-bottom: 0; }
.frame::after  { bottom: 1.1rem; right: 1.2rem; border-left: 0; border-top: 0; }

/* --- top strip --- */

.strip {
  grid-column: 1 / -1;
  text-shadow: var(--shadow-text);
  transition: opacity .45s ease;
  display: flex; justify-content: space-between; align-items: baseline;
  font-family: var(--mono);
  font-size: 0.65625rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--faint);
  animation: fade 0.6s 0.05s both;
}
.site-nav { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; }
.strip a { color: var(--faint); text-decoration: none; transition: color var(--transition-fast); }
.strip a:hover { color: var(--ink); }
.strip .sep { color: rgba(234, 240, 246, 0.22); }
.strip .here { color: var(--dim); }

/* --- the hero: wordmark and the one control --- */

.hero {
  align-self: center; justify-self: center; text-align: center;
  transition: opacity .45s ease;
}

h1 {
  font-family: var(--mono);
  font-weight: 300;
  font-size: clamp(2.8rem, 7vw, 5.5rem);
  line-height: 1;
  /* text-indent MUST equal letter-spacing on centred tracked type, and the
     1:1 pairing is not a coincidence — it is the correction.
     letter-spacing adds its space after every character including the last,
     and that trailing space is inside the line box being centred, so the ink
     sits left by half of it. Indenting by the full tracking puts the line's
     start right by the same amount and the ink lands centred:
         ink offset = (indent - tracking) / 2
     Measured: at indent 0 the wordmark sits 9.92 px left, exactly half the
     19.82 px tracking; at indent == tracking it is 0.00.
     Do not "simplify" this away by measuring a Range over the whole line —
     that rect INCLUDES the trailing space, so it reports centred when the
     glyphs are not, which is how this got removed once already. Measure the
     first and last characters and subtract one tracking step. */
  letter-spacing: 0.30em;
  text-indent: 0.30em;
  color: var(--ink);
  margin: 0;
  /* Near-white type at weight 300 over a sunlit cloud top is the worst case
     on this page — thin strokes, and a backdrop that can be brighter than the
     ink. The old single soft glow was lift, not contrast, and the wordmark
     disappeared into a bright reel frame.
     Three layers, all in em so they scale with the clamp: a hairline contact
     shadow, a tight halo hugging the strokes, and the soft lift that was here
     before at half strength. Proportionally this is the same treatment
     .rail-head gets at 11px — nothing wider than 0.18em, so the shadow only
     ever appears where a letter is standing on it rather than pooling in the
     tracking. */
  text-shadow:
    0 0.012em 0.02em rgba(3, 8, 14, 0.95),
    0 0 0.05em rgba(3, 8, 14, 0.85),
    0 0.03em 0.18em rgba(3, 8, 14, 0.5);
  animation: rise 0.75s 0.075s cubic-bezier(.2, .7, .2, 1) both;
}
.tag {
  /* --shadow-text plus a tight halo of its own: it sits directly under the
     wordmark, so it meets the same bright cloud. */
  text-shadow: var(--shadow-text), 0 0 0.35em rgba(3, 8, 14, 0.9);
  font-family: var(--mono);
  font-size: 0.6875rem;
  letter-spacing: 0.24em;
  text-indent: 0.24em;          /* must equal the tracking — see h1 */
  text-transform: uppercase;
  color: var(--dim);
  margin: 1.2rem 0 0;
  animation: fade 0.7s 0.25s both;
}

/* While a demo is previewing, the still is the subject and everything else
   gets out of its way — no movement, only a fade, because the wordmark
   shifting under the cursor read as a twitch. The hovered case is the one
   exception: it stays at full strength so you know what you are looking at.
   Harder than the 0.2 this page used to fade to: the point of the layout is
   to see the domain, and at 0.2 the chrome is still a page with a picture
   behind it rather than the picture. */
body.previewing .strip,
body.previewing .rail-head,
body.previewing .readout,
body.previewing .soon,
body.previewing .demo { opacity: 0.08; }
body.previewing .demo.live { opacity: 1; }
/* The hero goes to nothing rather than to 0.08. It is the one piece of chrome
   sitting in the middle of the picture, and at 0.08 a 5.5rem wordmark is
   still a grey shape across the sky you are trying to look at. Everything
   else is small and at the edges, so a trace of it reads as chrome resting
   rather than as something in the way.
   Deliberately still clickable: the fade back takes 350 ms of grace plus
   0.45 s, and pointer-events: none here would swallow the click of someone
   already moving from a card to the button. */
body.previewing .hero { opacity: 0; }

/* --- primary: bring your own --- */

/* The only control on the page, and the only one that floats directly over
   sky rather than sitting inside a panel — which is why the viewer's own
   14%-hot fill is not enough on its own. Inside the pause menu that fill
   sits on an opaque panel and reads solid; alone over a sunlit cloud top it
   is 14% and the cloud comes through it. So it is given the panel to sit on:
   the amber wash over --panel-bg is button.resume as the viewer draws it,
   composited rather than assumed. Shadow and wash are both lighter than a
   panel's — a panel-weight shadow under a control this small reads as a slab
   sitting on the sky rather than as lift. */
.own {
  /* Positioned so the arrow can be taken out of the flow — see .own::before. */
  position: relative;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--ui-font);
  font-size: 0.8125rem;
  color: var(--ink);
  background:
    linear-gradient(rgba(232, 131, 74, 0.10), rgba(232, 131, 74, 0.10)),
    var(--panel-bg);
  border: 1px solid var(--amber);
  border-radius: var(--panel-radius);
  /* Wider than the vertical padding on both sides, because the arrow now
     stands inside the left edge and the longest label — which sets the
     column's width and so fills its own box — would otherwise come within a
     hair of it. Symmetric, so the centring is still the box's centring. */
  padding: 0.85rem 2.1rem;
  cursor: var(--cursor-dart-click);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
  transition: background .15s ease;
}
/* The entry animations run ONCE, on the first paint, and are then taken off
   the elements entirely — see body.intro in main.js.
   They cannot stay: a `display: none` element does not run its animation, it
   runs it when it is first displayed. So `animation: fade 0.7s 0.425s both` on
   a control that basic mode hides meant that switching to research left the
   newly revealed button invisible for 0.425 s and then fading for 0.7 s, long
   after the click that asked for it. The intro is an intro; a mode switch is
   an interaction, and it gets the .3s reveal below instead.

   Durations and delays across the whole sequence — strip, h1, tag, own,
   modes, cases — are half what they were (Thomas, 2026-08-22). Halved
   together, so the stagger still reads as one cascade rather than as six
   things that happen to move. main.js's teardown timer is halved with
   them; it has to outlast the last one. */
body.intro .own { animation: fade 0.7s 0.425s both; }
/* hidden must beat display:flex, and does not on its own. */
.own[hidden] { display: none; }

/* One column, widest control setting the width for all of them: three
   buttons of three different widths stacked centre-aligned is a ragged edge
   down both sides, and these are three instances of one control rather than
   three different offers. width: max-content on the column resolves to the
   widest item, and stretch then gives every button that width. */
.choices {
  display: flex; flex-direction: column; align-items: stretch;
  gap: 0.6rem;
  /* The min-width is not decoration. Without it the column is max-content,
     which in basic mode is the widest of two buttons and in research the
     widest of three — so revealing "Render your own…" widened the stack by
     14 px and the centred hero jumped sideways at the very moment it was
     supposed to start gliding. A floor wider than the longest label makes the
     width mode-independent and the jump disappears.
     The max-width is the other end of the same argument: the caution inside
     the more button becomes a full sentence when the limits reject the field,
     and max-content on a sentence is one very long line. */
  width: max-content;
  min-width: 19rem;
  max-width: min(100%, 24rem);
  margin: 2.2rem auto 0;
}
/* Label over caution, inside the button, between the two arrows. */
.own .own-in {
  display: flex; flex-direction: column; align-items: center;
  gap: 0.3rem;
  min-width: 0;
}
/* The caution is not a disclosure here — it is open from the moment main.js
   knows what it says. Left-aligned inside a centred label because it is a
   sentence with a hanging indent under its mark (see .demo .warn), and a
   centred sentence with a hanging indent is neither. */
.own .warn { text-align: left; }
.own .warn:empty { display: none; }
/* The manifest did not have the case. An error state rather than a quiet
   substitution of some other field — see wireFlyPair in main.js. */
.own.missing { border-color: var(--alarm); border-style: dashed; }
/* The marker sits on the left edge of the box rather than beside the words:
   inset from that edge by the same amount as the vertical padding, so its
   left, top and bottom margins are one value, and vertically centred so the
   two-line button (label over caution) reads the same as the one-line ones.
   Out of flow is what lets the LABEL stay centred in the whole button — an
   arrow in the flex row shifts the words right of centre and needs a hidden
   mirror on the right to answer it, which is what this used to be. */
.own::before {
  content: '▸'; color: var(--amber);
  position: absolute;
  left: 0.85rem;
  top: 50%;
  transform: translateY(-50%);
  line-height: 1;
}
.own:hover:not([disabled]) {
  background:
    linear-gradient(rgba(232, 131, 74, 0.22), rgba(232, 131, 74, 0.22)),
    var(--panel-bg);
}
.own[disabled] { opacity: 0.45; cursor: var(--cursor-dart); }
.own.dropping { border-style: dashed; }
#file-input { display: none; }

/* --- the mode toggle ------------------------------------------------------
   One panel divided into three, rather than three buttons: the segments are
   exclusive and the shared surface says so.

   The selection is a single amber block that SLIDES between segments rather
   than a wash that switches on in one and off in another. Three washes
   crossfading say "this one is on now"; one block moving says "this is the
   same selection, and it is over there now" — which is what an exclusive
   control means, and it also carries the eye to the segment whose page is
   about to change underneath it. Its geometry is measured from the segments
   in main.js (placeThumb) because the labels are different lengths and
   nothing in CSS can know them. */

.modes {
  position: relative;
  display: inline-flex;
  margin-top: 1.4rem;
  background: var(--panel-bg);
  border: 1px solid var(--panel-line);
  border-radius: var(--panel-radius);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
  overflow: hidden;
}
body.intro .modes { animation: fade 0.7s 0.5s both; }

/* Behind the labels, above the panel. Left and width are set from JS; the
   transition is added only once it has been placed for the first time
   (.modes.ready), or the block would slide in from the left edge on load. */
.modes .thumb {
  position: absolute;
  top: 0; bottom: 0; left: 0;
  width: 0;
  background: linear-gradient(rgba(232, 131, 74, 0.22), rgba(232, 131, 74, 0.22));
  border-bottom: 2px solid var(--amber);
  pointer-events: none;
}
.modes.ready .thumb {
  transition: transform .32s cubic-bezier(.4, .1, .2, 1), width .32s cubic-bezier(.4, .1, .2, 1);
}
.modes .mode {
  position: relative;
  z-index: 1;
  font-family: var(--mono);
  font-size: 0.625rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--faint);
  background: none;
  border: 0;
  padding: 0.6rem 1.1rem;
  cursor: var(--cursor-dart-click);
  transition: color var(--transition-fast), background var(--transition-fast);
}
.modes .mode + .mode { border-left: 1px solid var(--panel-line); }
.modes .mode:hover { color: var(--ink); }
/* Only the ink changes here; the block behind it is the thumb. */
.modes .mode[aria-checked="true"] { color: var(--ink-bright); }
.modes .mode:focus-visible { outline: 1px solid var(--amber); outline-offset: -2px; }

/* --- basic vs research ----------------------------------------------------
   Basic drops the rail and the file picker; the hero then belongs in the
   middle of the sky rather than in the middle of what the rail leaves.
   Cyberpunk is basic's page with a city under it, so it takes every rule
   here: the same two buttons, no rail, no upload.

   It has to GET there rather than appear there. The first cut switched
   grid-template-columns between two tracks and one, which is a discrete
   change — the wordmark teleported a hundred and eighty pixels the instant
   the toggle was clicked, and the eye reads a jump as a glitch rather than as
   a layout.

   Animating grid-template-columns directly does not work: the two values have
   different track counts, and even matched they interpolate inconsistently
   across engines. So the track is `auto` and the WIDTH OF THE RAIL is what
   moves — an ordinary length on an ordinary element, which every engine
   transitions. The track follows it each frame, column 2 takes up the slack,
   and the centred hero glides. The gap goes with it, or a zero-width rail
   would still be pushing the hero 3rem right of centre.

   The rail keeps `display` throughout: display is not animatable, and a
   display:none rail has no width to transition from. pointer-events and the
   opacity fade do the hiding.

   Both transitions are scoped to body.switched — the class applyMode adds for
   a click and not for a page load — because the glide is the answer to "where
   did the rail go", and on load there was no question. The body has no mode
   class for the first instant of a load, which is the research layout, so a
   transitioned collapse meant every basic-mode page load began with the
   wordmark sliding in from the right as though the reader had just switched
   (Thomas, 2026-08-22). Unscoped it cannot be fixed by ordering: the class
   lands before the first paint, and a transition fires on the change whenever
   it lands. */

body.switched .cases { transition: width .42s cubic-bezier(.4, .1, .2, 1),
                                   padding .42s cubic-bezier(.4, .1, .2, 1),
                                   opacity .26s ease; }
body.switched .frame { transition: column-gap .42s cubic-bezier(.4, .1, .2, 1); }

body.mode-basic .cases,
body.mode-cyberpunk .cases {
  width: 0;
  padding-left: 0; padding-right: 0;
  opacity: 0;
  pointer-events: none;
}
body.mode-basic .frame,
body.mode-cyberpunk .frame { column-gap: 0; }
/* #choice-open, not .own — the two demo buttons are .own too now, and they
   are the whole of what basic offers. */
body.mode-basic #choice-open,
body.mode-basic #file-input,
body.mode-cyberpunk #choice-open,
body.mode-cyberpunk #file-input { display: none; }
/* Revealed by a click rather than by page load, so it gets the short reveal
   and not the intro's 0.85s hold. Scoped to body.switched so it never runs
   for someone whose stored mode is already research. */
body.switched.mode-research #choice-open { animation: fade .3s both; }

/* --- the cases ------------------------------------------------------------
   One row per case, each its own opaque surface rather than rows sharing one
   panel. That is what makes the fade work: with a single container the
   panel's background stays put and the sky never comes through. Separate
   surfaces fade to actual sky. */

.cases {
  align-self: center;
  width: 21rem;
  max-width: 40vw;
  /* Setting overflow-y alone does not leave overflow-x at `visible`: the spec
     computes the other axis to `auto` whenever one axis is not visible. So
     this is a scroll container in both directions, and a scroll container
     CLIPS at its padding box — which sliced the left end of each heading's
     halo and the sides of every card shadow off with a straight vertical
     edge. It read as a rendering artifact because a hard cut is exactly what
     it was.
     The padding is the slack the shadows need; the matching negative margin
     puts the content back where the column wants it, so nothing moves. Keep
     the padding at least as wide as the widest shadow here (heading halo
     6 px, card shadow 4 px). */
  overflow-y: auto;
  max-height: 100%;
  /* A card that opens on hover can be what tips the column into scrolling,
     and a scrollbar arriving mid-hover would shift the whole list sideways
     under the pointer. Reserving the gutter costs nothing where scrollbars
     are overlaid (where the property is defined to do nothing) and stops the
     jump where they are not. */
  scrollbar-gutter: stable;
  padding: 0.5rem 0.5rem;
  margin: 0 -0.5rem;
}
body.intro .cases { animation: fade 0.8s 0.55s both; }

/* The group headings are the only type on the page that names what the list
   below it IS, and they were the hardest thing on it to read: --faint at 10px
   over a sunlit cloud top is about 2:1, which is the frosted-card problem
   again in text form. Everything else here either sits on an opaque row or is
   a readout you go looking for; a section heading has to land without being
   looked for.

   Fixed with contrast rather than with a box, because a heading in a panel
   would read as another card and the list would stop being a list: brighter
   ink, a size and weight up, and a halo. --shadow-text is tuned for body copy
   on this page and is not enough under 11px.

   The halo has to stay TIGHT to the letterforms. The first cut ran three
   layers out to 20px, which stopped being an edge and became a dark cloud —
   visible as a smudge off each end of the line, where there are no glyphs to
   explain it. Two layers, nothing past 6px: the shadow now only ever appears
   where a letter is standing on it. */
.rail-head {
  text-shadow:
    0 1px 2px rgba(3, 8, 14, 0.95),
    0 0 6px rgba(3, 8, 14, 0.8);
  transition: opacity .45s ease;
  display: flex; align-items: center; gap: 0.9rem;
  font-family: var(--mono);
  font-size: 0.6875rem; font-weight: 600;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink);
  margin: 0 0 0.6rem;
}
/* The group rule is the panel head's amber rule run out to the edge — the
   same signal doing the same job one level up. Brought up with the label: at
   0.55 it was a hairline of orange on cloud and read as an artifact. No
   shadow under it — a 3px blurred dark band beneath a 1px line is thicker
   than the line, so it read as a second, smudged rule rather than as lift. */
.rail-head .rule {
  flex: 1; height: 1px;
  background: var(--amber);
  opacity: 0.8;
}
.rail-group + .rail-group { margin-top: 1.3rem; }
.rail-loading { margin: 0; font-size: 0.6875rem; color: var(--faint); }

/* A group with nothing in it yet. Not a disabled card: a card that cannot be
   clicked reads as a demo that is broken, and this one is only unwritten. */
.soon {
  margin: 0;
  padding: 0.55rem 0.8rem;
  border: 1px dashed var(--line-soft);
  border-radius: var(--panel-radius);
  text-shadow: var(--shadow-text);
  transition: opacity .45s ease;
  font-family: var(--mono);
  font-size: 0.625rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--faint);
}

.demo {
  display: block;
  width: 100%;
  text-align: left;
  color: var(--ink);
  font-family: var(--ui-font);
  background: var(--panel-bg);
  border: 1px solid var(--panel-line);
  border-radius: var(--panel-radius);
  /* NOT the viewer's 0 12px 40px panel shadow, which is sized for one large
     box floating alone. These rows are 6 px apart, so a 12 px drop at 40 px
     blur lands entirely inside the gap to the next row and paints it —
     reading as a dark strip under every card rather than as lift under any
     of them.
     The invariant: offsetY + blur must stay under the 0.4rem row gap, or the
     shadow is no longer under its own card. 1 + 4 = 5 against 6. */
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.40);
  padding: 0.6rem 0.8rem 0.65rem;
  cursor: var(--cursor-dart-click);
  transition: border-color .2s ease, opacity .45s ease;
}
.demo + .demo { margin-top: 0.4rem; }
.demo:hover:not([disabled]), .demo:focus-visible {
  border-color: var(--amber);
  outline: none;
}
.demo[disabled] { opacity: 0.45; cursor: var(--cursor-dart); }
.demo.live { border-color: var(--amber); }

/* No GPU adapter. These stay clickable on purpose — main.js answers the click
   with the reason rather than swallowing it — so they read as unavailable
   without reading as inert. Keep the pointer: they still do something. */
#rail.unavailable .demo,
.own.unavailable { opacity: 0.5; border-style: dashed; }

.demo .top {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 0.8rem;
}
.demo .name {
  font-family: var(--mono);
  font-weight: 500;
  font-size: 0.78125rem;
  letter-spacing: 0.02em;
  color: var(--ink-bright);
  /* A flex item will not shrink below its content by default, so a long name
     pushed the resolution into wrapping instead of wrapping itself. The name
     is the thing with room to take a second line. */
  min-width: 0;
}
/* The horizontal grid spacing, in the corner the download size used to have.
   A file size tells you how long you will wait; the resolution tells you what
   you are about to fly through, and only one of those is worth a corner.
   Never broken across lines: "100" over "m" is not a number, and this is the
   one field on the card whose whole job is to be compared down the column. */
.demo .res {
  font-family: var(--mono);
  font-size: 0.65625rem;
  color: var(--ink);
  flex: none;
  white-space: nowrap;
}
.demo .field {
  display: block;
  font-size: 0.71875rem;
  color: var(--dim);
  margin-top: 0.1rem;
}
/* Grid on the left, domain on the right, column-aligned down the whole list
   so the eye can run the cases against each other — which is the entire
   reason the numbers are on the page at all. */
.demo .data {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0 0.6rem;
  margin-top: 0.4rem;
  padding-top: 0.4rem;
  border-top: 1px solid rgba(150, 172, 200, 0.12);
  font-family: var(--mono);
  font-size: 0.65625rem;
  color: var(--ink);
}
.demo .data > :last-child { text-align: right; }
.demo .data .u { color: var(--faint); }

/* --- what a case says beyond its numbers ---------------------------------
   A caution, a sentence, or neither. The card grows to hold it while it is
   the row under the pointer, and closes again after — so the list stays a
   list of comparable specifications at rest, and the one row you are
   considering is the one that talks.

   0fr -> 1fr on a grid row rather than a max-height guess: the height
   animated to is the content's own, so a two-line caution and a four-line
   description both open exactly far enough. The inner span is what clips,
   because a grid item does not shrink below its content without min-height.

   Tied to .live as well as :hover so it follows the same state the backdrop
   preview does — including the 350 ms grace on leave, and including focus,
   which main.js drives through the same handler. */
.demo .more {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows .22s ease;
}
.demo:hover .more,
.demo:focus-visible .more,
.demo.live .more { grid-template-rows: 1fr; }
.demo .more-in { overflow: hidden; min-height: 0; }

/* Both inherit the card's UI face rather than taking the mono: these are
   sentences. The numbers above are mono because they are meant to be read
   down the column against each other, and prose in that face would join a
   table it is not part of. */
.demo .warn, .demo .note, .own .warn {
  display: block;
  padding-top: 0.45rem;
  font-size: 0.6875rem;
  line-height: 1.5;
}
/* The mark and the sentence are two flex items, which is what gives the
   second line its hanging indent: the text is one item and wraps inside its
   own box, so it cannot run back under the mark.
   This replaces a padding-left/negative-text-indent pair, and the flex
   version is the safer of the two — text-indent is INHERITED, so the -1.85em
   reached into the mark's own inline formatting context and laid its glyph
   out 20 px to the left of itself, which on the page looked like an empty
   box with nothing in it. */
.demo .warn, .own .warn {
  color: var(--alarm);
  display: flex;
  align-items: baseline;
  gap: 0.45em;
}
.demo .note { color: var(--dim); }
/* Bare, and heavier and larger than the line it introduces — the weight is
   the whole signal now that there is no box around it. Boxed, it was a
   14 px square holding an 11 px glyph and read as small print about small
   print (Thomas, 2026-08-15). flex: none so it never gets squeezed by a
   long first word. */
.demo .warn::before, .own .warn::before {
  content: '!';
  flex: none;
  font-size: 1.35em;
  font-weight: 800;
  line-height: 1;
}

/* No hover to open it with. Touch has no third state between "at rest" and
   "activated", and a tap on one of these rows loads a field — so a caution
   that only appears on hover would only appear after it stopped mattering. */
@media (hover: none) {
  .demo .more { grid-template-rows: 1fr; }
}

/* --- bottom readout --- */

.readout {
  grid-column: 1 / -1;
  text-shadow: var(--shadow-text);
  transition: opacity .45s ease;
  display: flex; justify-content: space-between; align-items: flex-end;
  gap: 2rem;
  font-family: var(--mono);
  font-size: 0.65625rem;
  letter-spacing: 0.04em;
  color: var(--faint);
  margin-top: 1rem;
}
#capability { max-width: 46rem; line-height: 1.6; }
#capability b { color: var(--dim); font-weight: 400; }
#capability .detail { display: block; margin-top: 0.35rem; color: var(--faint); }
#capability.good b { color: #7fd6a0; }
#capability.warn b { color: var(--amber); }
#capability.bad { color: var(--alarm); }
#capability.bad b { color: var(--alarm); }

@keyframes rise { from { opacity: 0; transform: translateY(0.35em); letter-spacing: 0.34em; } }
@keyframes fade { from { opacity: 0; } }

/* One column, and the hero leads — on a phone the wordmark should arrive
   before a list of cases rather than under it. */
@media (max-width: 820px) {
  html, body { overflow: auto; }
  .frame {
    grid-template-columns: 1fr;
    grid-template-rows: auto auto auto auto;
    padding: 1rem 1.1rem 1.2rem;
    min-height: 100%;
    row-gap: 2rem;
  }
  .hero { order: 1; margin-top: 1.5rem; }
  /* Full width, and no column to slide: one column already. */
  .cases { order: 2; overflow: visible; max-height: none;
           width: auto; max-width: none; }
  /* display, not a zero width: there is no second column here for the hero to
     slide across, so there is no animation to keep alive — and a zero-width
     rail in a one-column grid still holds its padding and its row gap open. */
  body.mode-basic .cases,
  body.mode-cyberpunk .cases { display: none; }
  .readout { order: 3; flex-direction: column; align-items: flex-start; }
  .own { padding: 0.75rem 1.8rem; }
  /* The arrow keeps its inset equal to the (smaller) vertical padding here. */
  .own::before { left: 0.75rem; }
  .choices { width: auto; }
}

/* --- loading / error takeover --- */

#loading {
  position: fixed;
  inset: 0;
  z-index: 1010;
  background: var(--void);
  display: grid;
  place-items: center;
  padding: 2rem;
}
#loading[hidden] { display: none; }
#loading .box { max-width: 34rem; text-align: left; width: 100%; }
#loading .stage {
  font-size: 0.8125rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--dim);
  margin-bottom: 1rem;
}
#loading .bar {
  height: 2px;
  background: var(--line-soft);
  overflow: hidden;
}
#loading .bar span {
  display: block;
  height: 100%;
  width: 0;
  background: var(--amber);
  transition: width 0.2s ease;
}
#loading .failure { display: none; }
#loading.failed .bar { display: none; }
#loading.failed .failure { display: block; }
#loading .failure h2 {
  font-family: var(--mono);
  font-size: 1.5rem;
  font-weight: 400;
  letter-spacing: 0.02em;
  margin: 0 0 0.75rem;
}
#loading .failure p {
  font-size: 0.8125rem;
  line-height: 1.75;
  color: var(--dim);
}
#loading .failure .advice { color: var(--ink); }
#loading .failure button {
  font-family: var(--mono);
  font-size: 0.75rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  background: none;
  border: 1px solid var(--line);
  padding: 0.7rem 1.4rem;
  color: var(--ink);
  margin-top: 1.25rem;
  cursor: var(--cursor-dart-click);
  transition: border-color var(--transition-fast);
}
#loading .failure button:hover { border-color: var(--amber); }
