/* jjscoolwebsite.com — the quest layer.

   The point-and-click half of the site: the voice at the door, the
   inventory, and the moment the page stops being grey. Loaded on every
   chrome page since this state is the visitor's, not any one page's (see
   layouts/base.njk). Built from the same kit as the top bar: --face
   panels, --raised edges, yellow lettering, whole-pixel bevels.

   Three stages, written on <html> by main.js:
     data-quest="intro"  voice talking, site behind dead
     data-quest="boring" voice done, site grey and unclickable
     data-quest="cool"   mouse equipped, colour back

   Set only by script, never by the server — a visitor with no JS never
   enters a stage and reads an ordinary site, since a gate a broken script
   can shut for good is a wall, not a gate. */

/* Beats the default [hidden] rule's specificity for flex/grid children. */
.quest [hidden] {
  display: none !important;
}

/* --- the drain ------------------------------------------------------
   The "boring front page": same page, colour pulled out, so its return is
   unmistakably the same page waking up, not a different screen.

   Applied per chrome region rather than to <body>: a filter makes its
   element the containing block for fixed descendants, and the quest layer
   (fixed, full-viewport) would size to body's flexbox instead. Doing it
   per-region also keeps the quest layer itself in full colour — the only
   bright thing on screen. */

.topbar,
main,
.ticker {
  /* Only animates on the twice-a-visit attribute change. */
  transition: filter 900ms cubic-bezier(0.2, 0.9, 0.3, 1);
}

/* Checkerboard lives behind the three regions, so it's drained separately
   — on <body>, not :root, since the quest layer reads --bg off :root for
   its own wells (which keep their colour). */
[data-quest="intro"] body,
[data-quest="boring"] body {
  --bg-tile: #2f2f36;
  background-color: #26262c;
}

body {
  transition: background-color 900ms cubic-bezier(0.2, 0.9, 0.3, 1);
}

[data-quest="intro"] :is(.topbar, main, .ticker),
[data-quest="boring"] :is(.topbar, main, .ticker) {
  filter: grayscale(1) brightness(0.72) contrast(0.85);
  /* Links stay but don't answer; focus can't tab into the dead site. */
  pointer-events: none;
  user-select: none;
}

[data-quest="intro"] :is(.topbar, main, .ticker) *,
[data-quest="boring"] :is(.topbar, main, .ticker) * {
  /* Inert-by-visibility in one rule, rather than tabindex on every
     control; focus follows pointer-events for mouse but not tab. */
  outline: none;
}

/* Freeze the flag/letters/sparkles so a grey page reads as dormant, not
   broken — colour returning also means movement returning. */
[data-quest="intro"] :is(main, .ticker) *,
[data-quest="boring"] :is(main, .ticker) * {
  animation-play-state: paused !important;
}

/* --- the voice --------------------------------------------------------
   Scrim, item above, box at the bottom — the classic talking-head shape. */

.quest__scene {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 2rem;
  padding: 1.25rem;
  /* Dark enough to kill the page, short of black so it's still visible. */
  background: rgba(16, 14, 26, 0.82);
}

/* The intro's last lines run over the real page, not the scrim: scrim
   gone, box stays, clicks pass through everything but the box. */
.quest__scene--quiet {
  background: transparent;
  pointer-events: none;
}

.quest__scene--quiet .quest__box {
  pointer-events: auto;
}

.quest__box {
  width: 100%;
  max-width: 44rem;
  margin-bottom: clamp(1rem, 6vh, 4rem);
  padding: 1.25rem calc(1.25rem + var(--bevel));
  background: var(--face);
  box-shadow: var(--raised);
  border: var(--rule) solid var(--dim);
}

/* Added by main.js only while an `{ excited: true }` line is still
   typing. Whole-pixel jitter, not a smooth wobble, to match the rest. */
.quest__box--shake {
  animation: quest-box-shake 130ms steps(2, end) infinite;
}

@keyframes quest-box-shake {
  from { translate: -2px 0; }
  to   { translate: 2px 0; }
}

/* min-height for the longest beat's 3 lines, so typing doesn't resize it. */
.quest__line {
  margin: 0;
  min-height: 4.8em;
  color: var(--yellow);
  font-size: clamp(0.95rem, 2.4vw, 1.15rem);
  line-height: 1.6;
  text-shadow: 2px 2px 0 var(--dim);
}

/* Blinking ▼, bottom-right, where the era always put it. */
.quest__next {
  --bevel: 2px;
  display: block;
  margin-left: auto;
  padding: 0.1rem 0.6rem;
  border: 0;
  background: transparent;
  box-shadow: none;
  color: var(--yellow);
  font: inherit;
  font-size: 1.1rem;
  animation: quest-nudge 900ms steps(2, end) infinite;
}

@keyframes quest-nudge {
  from { translate: 0 0; }
  to   { translate: 0 4px; }
}

/* Reserved at one control row's height from the start of typing, so the
   box doesn't grow when the ▼/form/choices appear. */
.quest__controls {
  margin-top: 0.9rem;
  min-height: 2.4rem;
}

.quest__form,
.quest__choices {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
}

/* Sunken well, like the now-playing readout — its own edge colours since
   this face is the page's navy, not the bar's lavender. */
.quest__input {
  --bevel: 2px;
  --lit: var(--bg-lit);
  --dim: var(--bg-dim);
  flex: 1 1 12rem;
  min-width: 0;
  padding: 0.4rem calc(0.5rem + var(--bevel));
  border: 0;
  background: var(--bg);
  box-shadow: var(--sunken);
  color: var(--yellow);
  font: inherit;
  font-size: 0.95rem;
}

.quest__btn {
  --bevel: 2px;
  padding: 0.4rem 1.1rem;
  border: 0;
  background: var(--face);
  box-shadow: var(--raised);
  color: var(--yellow);
  font: inherit;
  font-size: 0.9rem;
  font-weight: 700;
}

.quest__btn:hover,
.quest__btn:focus-visible {
  background: #574672;
}

.quest__btn:active {
  box-shadow: var(--sunken);
  translate: 1px 1px;
}

/* A ► beside the selected choice, keyboard or mouse, moved with arrow
   keys — sideways version of .quest__next's ▼. Reserved as padding so
   the row doesn't reflow as it moves. */
.quest__choices .quest__btn {
  position: relative;
  padding-left: calc(1.1rem + 0.9rem);
}

.quest__choices .quest__btn::before {
  content: '\25b6';
  position: absolute;
  left: 0.5rem;
  top: 50%;
  translate: 0 -50%;
  opacity: 0;
}

/* Driven by main.js's `choiceIndex` class, not :focus — a marker keyed
   only to focus vanishes when focus moves elsewhere (or, in Safari,
   never lands on click), and this should behave like a menu cursor
   that's always on exactly one option. */
.quest__choices .quest__btn--selected::before {
  opacity: 1;
  animation: quest-nudge-x 900ms steps(2, end) infinite;
}

/* The site's focus-visible ring (styles.css) is the browser's to drop on
   a stray click; painting it here too, off the same selected class,
   means the highlight tracks the cursor and never just goes out. */
.quest__choices .quest__btn--selected {
  outline: 3px solid var(--yellow);
  outline-offset: 2px;
}

@keyframes quest-nudge-x {
  from { translate: 0 -50%; }
  to   { translate: 4px -50%; }
}

/* --- the item on its pedestal ------------------------------------------
   Spinning and bobbing, waiting to be picked up. scaleX rather than
   rotateY, since a flat sprite would vanish edge-on for a frame under a
   real 3D rotation — scaleX keeps the pixels pixels the whole way round. */

.quest__pedestal {
  display: flex;
  flex: 1;
  align-items: center;
}

.quest__item {
  padding: 0;
  border: 0;
  background: none;
  animation: quest-hover 1.6s ease-in-out infinite;
}

.quest__item img {
  display: block;
  width: 96px;
  height: 96px;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  /* The glow it stands in — brightest thing in a dark room, not a shadow. */
  filter: drop-shadow(0 0 12px rgba(251, 242, 84, 0.55));
  animation: quest-spin 1.8s linear infinite;
}

@keyframes quest-hover {
  0%, 100% { translate: 0 -6px; }
  50%      { translate: 0 6px; }
}

/* Even time steps but cos(theta) scaleX values — what constant angular
   velocity actually looks like (fast edge-on, slow at full width). The
   easing lives in that curve, so the animation itself runs linear;
   stacking ease-in-out on top made the spin stutter. */
@keyframes quest-spin {
  0%   { transform: scaleX(1); }
  6.25%   { transform: scaleX(0.92); }
  12.5%  { transform: scaleX(0.71); }
  18.75%  { transform: scaleX(0.38); }
  25%  { transform: scaleX(0.06); }
  31.25%  { transform: scaleX(-0.38); }
  37.5%  { transform: scaleX(-0.71); }
  43.75%  { transform: scaleX(-0.92); }
  50%  { transform: scaleX(-1); }
  56.25%  { transform: scaleX(-0.92); }
  62.5%  { transform: scaleX(-0.71); }
  68.75%  { transform: scaleX(-0.38); }
  75%  { transform: scaleX(0.06); }
  81.25%  { transform: scaleX(0.38); }
  87.5%  { transform: scaleX(0.71); }
  93.75%  { transform: scaleX(0.92); }
  100% { transform: scaleX(1); }
}

/* --- the GET ------------------------------------------------------------
   Over the top on purpose: item flies up out of a ray wheel, word chases
   it with three fainter ghost copies trailing a frame behind (offset by
   animation-delay, not drawn as frames). */

.quest__get {
  position: fixed;
  inset: 0;
  z-index: 50;
  /* The ray wheel is wider than any window it'll ever sit in. */
  overflow: hidden;
  cursor: pointer;
  /* Darker than the inventory/achievements scrim (0.72): this one also
     has to beat the bright yellow ray wedges, and can land over a page
     still in full colour (e.g. the shades pickup), not just the drained
     "cool" background it was first built for. */
  background: rgba(10, 9, 16, 0.88);
}

/* The cool cursor's own GET only — main.js's playGet() sets this off the
   item, since the stage hasn't flipped to 'cool' yet while this is up. */
.quest__get--light {
  background: none;
}

/* Dead centre of the ray wheel via margin, not translate (which the
   pop-in animation already owns). Plain box now, filled by itemSprite()
   in main.js, so its <img> needs its own fill rule. */
.quest__get-item {
  position: absolute;
  left: 50%;
  top: 50%;
  margin: -64px 0 0 -64px;
  width: 128px;
  height: 128px;
  display: grid;
  place-items: center;
  filter: drop-shadow(0 0 16px rgba(251, 242, 84, 0.8));
  animation: quest-get-item 1.5s cubic-bezier(0.15, 0.9, 0.25, 1) both;
}

.quest__get-item img {
  width: 100%;
  height: 100%;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

@keyframes quest-get-item {
  0%   { translate: 0 60px; scale: 0.2; opacity: 0; }
  35%  { translate: 0 -20px; scale: 1.35; opacity: 1; }
  55%  { translate: 0 0; scale: 1; }
  100% { translate: 0 0; scale: 1; opacity: 1; }
}

.quest__get-word {
  display: grid;
  place-items: center;
  color: var(--yellow);
  font-size: clamp(2.5rem, 12vw, 6rem);
  font-weight: 700;
  font-style: italic;
  letter-spacing: 0.05em;
  text-shadow: 4px 4px 0 var(--dim);
}

.quest__get-word > * {
  grid-area: 1 / 1;
  animation: quest-get-word 900ms cubic-bezier(0.1, 0.9, 0.2, 1) both;
}

/* Earlier start + dimmer per ghost, so they trail the real word. */
.quest__get-ghost {
  opacity: 0.35;
  color: var(--yellow-dark);
}

.quest__get-ghost:nth-child(1) { animation-delay: -150ms; opacity: 0.15; }
.quest__get-ghost:nth-child(2) { animation-delay: -100ms; opacity: 0.25; }
.quest__get-ghost:nth-child(3) { animation-delay: -50ms; }

@keyframes quest-get-word {
  0%   { translate: -60vw 0; scale: 0.6; }
  70%  { translate: 0 0; scale: 1.15; }
  100% { translate: 0 0; scale: 1; }
}

/* Bottom-anchored, one sprite-radius + gap above centre, growing upward.
   Solid white, not the background-clip:text rainbow trick — transforms
   broke that (compositor dropped the clip, painted the fill black). */
.quest__get-title {
  position: absolute;
  left: 50%;
  top: calc(50% - 64px - 1rem);
  translate: -50% -100%;
  margin: 0;
  max-width: 30rem;
  width: 100%;
  text-align: center;
  text-transform: uppercase;
  color: var(--white);
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: clamp(1.1rem, 3vw, 1.75rem);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-shadow: 3px 3px 0 var(--dim), 0 0 14px rgba(255, 255, 255, 0.4);
  animation:
    quest-title-pop 650ms cubic-bezier(0.2, 0.9, 0.3, 1.4) both,
    quest-title-wobble 2.2s ease-in-out 650ms infinite;
}

@keyframes quest-title-pop {
  0%   { scale: 0.3; opacity: 0; translate: -50% calc(-100% - 10px); }
  55%  { scale: 1.2; opacity: 1; translate: -50% calc(-100% + 4px); }
  75%  { scale: 0.92; }
  100% { scale: 1; translate: -50% -100%; }
}

@keyframes quest-title-wobble {
  0%, 100% { rotate: -1.5deg; }
  50%      { rotate: 1.5deg; }
}

/* Mirror of .quest__get-title, top-anchored, growing downward — keeps
   the word/description gap fixed to the block, not another sprite-anchor. */
.quest__get-below {
  position: absolute;
  left: 50%;
  top: calc(50% + 64px + 1rem);
  translate: -50% 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  width: 100%;
}

/* Comic Sans because the joke needs the real font. Rises in a beat after
   the title, then pulses colour slowly so it stays alive without
   competing with the title. Sparkles spin independently either side. */
.quest__get-description {
  position: relative;
  margin: 0;
  max-width: 30rem;
  padding: 0 1rem;
  color: var(--yellow);
  font-family: "Comic Sans MS", "Comic Sans", cursive, sans-serif;
  font-weight: 700;
  font-size: clamp(0.8rem, 1.6vw, 1rem);
  line-height: 1.4;
  text-align: center;
  text-shadow: 1px 1px 0 var(--dim);
  animation:
    quest-desc-in 500ms ease-out 200ms both,
    quest-desc-pulse 3s ease-in-out 700ms infinite;
}

.quest__get-description::before,
.quest__get-description::after {
  content: "\2726";
  display: inline-block;
  margin: 0 0.4em;
  color: var(--yellow-dark);
  text-shadow: none;
  animation: quest-desc-spin 1.8s linear infinite;
}

.quest__get-description::after {
  animation-direction: reverse;
}

@keyframes quest-desc-in {
  from { translate: 0 10px; opacity: 0; }
  to   { translate: 0 0; opacity: 1; }
}

@keyframes quest-desc-pulse {
  0%, 100% { color: var(--yellow); }
  50%      { color: var(--white); }
}

@keyframes quest-desc-spin {
  to { rotate: 360deg; }
}

/* The light wheel: one turning conic gradient, drawn not fetched (no
   request, nothing for the CSP to flag).

   Centred by hand outside the grid — load-bearing, since it's wider than
   the window by design, and as a grid item that width would become the
   cell's, pushing the item/word (centred in the same cell) off-screen. */
.quest__rays {
  position: absolute;
  left: 50%;
  top: 50%;
  /* Radius past the viewport's diagonal (vmax * sqrt(2), rounded up), so
     it covers every corner, not just the short side. */
  margin: -150vmax 0 0 -150vmax;
  width: 300vmax;
  aspect-ratio: 1;
  background: repeating-conic-gradient(
    rgba(251, 242, 84, 0.22) 0deg 7deg,
    transparent 7deg 14deg
  );
  border-radius: 50%;
  animation: quest-rays 8s linear infinite, quest-rays-in 600ms ease-out both;
}

@keyframes quest-rays {
  to { rotate: 360deg; }
}

@keyframes quest-rays-in {
  from { scale: 0; opacity: 0; }
  to   { scale: 1; opacity: 1; }
}

/* --- the inventory ------------------------------------------------------ */

.quest__inventory,
.quest__achievements {
  position: fixed;
  inset: 0;
  z-index: 45;
  display: grid;
  place-items: center;
  padding: 1.25rem;
  background: rgba(16, 14, 26, 0.72);
}

.quest__panel {
  width: 100%;
  max-width: 56rem;
  background: var(--face);
  box-shadow: var(--raised);
  border: var(--rule) solid var(--dim);
  padding: calc(0.9rem + var(--bevel));
}

/* Wireframe main.js travels between the bag button and the panel's own
   footprint by animating real left/top/width/height (not a transform, so
   the border never scales into a hairline) via a normal CSS transition —
   smooth, not the discrete jumps the window used to snap through. */
.quest__panel-outline {
  position: fixed;
  z-index: 46;
  box-sizing: border-box;
  border: 2px solid var(--yellow);
  pointer-events: none;
  transition: left 200ms ease-out, top 200ms ease-out, width 200ms ease-out, height 200ms ease-out;
}

/* Hidden while the wireframe travels, then faded in a beat after it
   arrives — reversed on close, so the window empties before the
   outline starts back. */
.quest__panel--hidden {
  opacity: 0;
}

.quest__panel--filling {
  animation: quest-panel-fill 90ms ease-out both;
}

.quest__panel--fading {
  animation: quest-panel-fill 90ms ease-in both reverse;
}

@keyframes quest-panel-fill {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.quest__panel-title {
  margin: 0 0 0.9rem;
  padding: 0.2rem 0.6rem;
  background: var(--bg);
  color: var(--yellow);
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.quest__panel-body {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
  align-items: flex-start;
}

.quest__panel-body > :first-child {
  flex: 1 1 16rem;
}

/* The equipment column (the figure and its wells) keeps its own footprint
   rather than growing: it's the backpack that takes up whatever slack the
   panel has. It will still shrink — only once it's alone on a line and
   narrower than the figure, where the alternative is hanging off the edge
   of the panel on a phone. The figure's own max-width takes it from there. */
.quest__panel-body > :last-child {
  flex: 0 1 auto;
}

.quest__label {
  margin: 0 0 0.4rem;
  color: var(--muted);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* --- the trophy case -----------------------------------------------------
   Two columns in the same panel the inventory uses: the checklist on the
   left, and the one row that's been picked blown up on the right. The
   checklist keeps its old width and the card takes the rest, which is what
   the space beside a list of four rows was always for. */
.quest__achv-body {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
  align-items: flex-start;
  margin-bottom: 0.9rem;
}

/* One row per achievement whether earned or not; a locked row blanks its
   name and swaps its description for the hint, so the list is the
   checklist. */
.quest__achv-list {
  display: grid;
  gap: 0.6rem;
  margin: 0;
  padding: 0;
  list-style: none;
  flex: 1 1 18rem;
  max-width: 24rem;
}

/* A button, so the row answers to a click, a tap and a tab on its own —
   stripped back to the box it was as an <li>, since what makes it a row is
   the bevel around it, not the control underneath. */
.quest__achv-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  width: 100%;
  padding: 0.6rem 0.75rem;
  border: 0;
  background: var(--bg);
  box-shadow: var(--sunken);
  font: inherit;
  color: inherit;
  text-align: left;
}

.quest__achv-row--done {
  background: var(--face);
  box-shadow: var(--raised);
}

/* Lit rather than recoloured: an earned row and a locked one sit on two
   different backgrounds, and one brightness step reads the same on both. */
.quest__achv-row:hover,
.quest__achv-row:focus-visible {
  filter: brightness(1.2);
}

/* The one the card is showing: a yellow bar down its inside edge. Not an
   outline and not another bevel — the outline belongs to the keyboard
   (a selected row can be focused or not, and both have to read at once),
   and the bevels are already saying earned or locked. */
.quest__achv-row--selected::before {
  content: "";
  position: absolute;
  left: var(--bevel);
  top: var(--bevel);
  bottom: var(--bevel);
  width: 4px;
  background: var(--yellow);
}

/* Silhouetted until earned, like the "???" name. Drawn at 2x the 32px
   source — the largest whole-pixel multiple pixelated can scale clean. */
.quest__achv-icon {
  flex: 0 0 auto;
  width: 64px;
  height: 64px;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  filter: grayscale(1) brightness(0.6);
  opacity: 0.5;
}

.quest__achv-row--done .quest__achv-icon {
  filter: none;
  opacity: 1;
}

.quest__achv-name {
  margin: 0;
  color: var(--muted);
  font-size: 0.85rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.quest__achv-row--done .quest__achv-name {
  color: var(--yellow);
}

.quest__achv-desc {
  margin: 0.25rem 0 0;
  color: var(--muted);
  font-size: 0.75rem;
  line-height: 1.4;
}

/* --- the card beside the list --------------------------------------------
   Always filled — there is always a selected row — so this is a wall of the
   panel rather than something that appears. Centred, because the art is the
   thing it is for and the lines under it are a caption to that. */
/* A wall of the panel, so it is one size whichever row is picked: a long
   flavour line, a two-line hint with a progress bar under it and a
   one-line deed all sit in the same box rather than jogging the panel (and
   the wireframe outline drawn around it) every time the selection moves.
   Tall enough for the longest card at the panel's full width; the few
   widths where the two columns are squeezed side by side and the text runs
   longer get a scroll inside the card instead of a taller one.

   Centred vertically, because a short card otherwise hangs from the top of
   a box it doesn't fill. `safe` so a card that does overflow still starts
   at the top, where the scroll can reach it. */
.quest__achv-detail {
  flex: 1 1 18rem;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: safe center;
  height: 31rem;
  overflow-y: auto;
  padding: 1rem;
  background: var(--bg);
  box-shadow: var(--sunken);
  text-align: center;
}

/* 8x the 32px source: as big as the panel will take at its widest, and
   still a whole-pixel multiple, which is the bargain the 64px list icon
   makes three sizes down. It shrinks below that on a narrow screen — a
   blurry trophy beats one hanging off the side — and stays square either
   way. Locked art is silhouetted here exactly as it is in the list. */
.quest__achv-art {
  display: block;
  /* Not squashed to make a long card fit the fixed-height box above: the
     card scrolls, the trophy keeps its size. */
  flex: 0 0 auto;
  width: 256px;
  max-width: 100%;
  height: auto;
  aspect-ratio: 1;
  margin: 0 auto 0.75rem;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  filter: grayscale(1) brightness(0.6);
  opacity: 0.5;
}

.quest__achv-detail--done .quest__achv-art {
  filter: none;
  opacity: 1;
}

.quest__achv-detail-name {
  margin: 0;
  color: var(--muted);
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.quest__achv-detail--done .quest__achv-detail-name {
  color: var(--yellow);
}

/* The receipt: when it was won, or the fact that it hasn't been. Green
   only once there is a time to print — the site's one "done" colour. */
.quest__achv-detail-when {
  margin: 0.3rem 0 0;
  color: var(--muted);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.quest__achv-detail--done .quest__achv-detail-when {
  color: var(--green);
}

/* What it took, in the same white the examine card next door gives an
   item's own line... */
.quest__achv-detail-deed {
  margin: 0.75rem 0 0;
  color: var(--white);
  font-size: 0.8rem;
  line-height: 1.5;
}

/* ...and then the long version of it, dropped back to the muted lavender
   so the deed stays the line that's read first. */
.quest__achv-detail-flavour {
  margin: 0.6rem 0 0;
  color: var(--muted);
  font-size: 0.75rem;
  line-height: 1.5;
}

/* The tally for a locked trophy that counts its way there: a sunken groove
   filled from the left, the same bar the music player's timeline is. */
.quest__achv-bar {
  --bevel: 2px;
  --lit: var(--bg-lit);
  --dim: var(--bg-dim);
  flex: 0 0 auto;
  height: 0.55rem;
  margin: 0.9rem 0 0;
  background: var(--bg-dim);
  box-shadow: var(--sunken);
  overflow: hidden;
}

.quest__achv-bar-fill {
  display: block;
  width: 0;
  height: 100%;
  background: var(--yellow-dark);
  transition: width 200ms ease-out;
}

.quest__achv-detail-count {
  margin: 0.4rem 0 0;
  color: var(--muted);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
}

/* Four across always, even empty — a grid with one thing in it still
   says "there will be more". */
.quest__bag {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Cell and slot share one well/edge style — the label above is what
   makes a slot a slot, not a different box. */
.quest__cell,
.quest__slot {
  --bevel: 2px;
  --lit: var(--bg-lit);
  --dim: var(--bg-dim);
  display: grid;
  place-items: center;
  width: 100%;
  aspect-ratio: 1;
  padding: 0;
  border: 0;
  background: var(--bg);
  box-shadow: var(--sunken);
}

/* The figure and the four wells that mirror it, side by side. */
.quest__equip {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: 0.75rem;
}

/* One row per slot, in the order the dummy's own labels read down the
   figure: head, body, cursor, legs. The column's width is stated rather
   than taken from the wells' own aspect-ratio: a width that only exists
   once a height is known doesn't count towards the panel's intrinsic
   width, and the column ended up hanging off the edge of it. */
.quest__wells {
  display: grid;
  gap: 0.5rem;
  width: 6.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* --- the dummy ------------------------------------------------------------
   A stack of same-sized pictures (the source art is 96x112 throughout) over
   four invisible buttons, one per region. Every .quest__dummy-img is exactly
   the container's own box; what changes is which ones are visible, not
   where any of them sit. */

.quest__dummy {
  position: relative;
  /* 400% of the source art's own 96px width, and on a screen too narrow
     for that, whatever is left of it once the scrim and the panel have
     taken their padding. Measured against the viewport rather than the
     parent (the obvious max-width: 100%) because every box between here
     and the panel is sized from its contents — this figure's own width
     among them — so a percentage would be measuring itself, and the panel
     would end up wider than the phone holding it. */
  width: min(24rem, calc(100vw - 7rem));
  aspect-ratio: 96 / 112;
  margin: 0 auto;
}

.quest__dummy-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  pointer-events: none;
}

.quest__dummy-labels {
  opacity: 0.55;
}

/* Eight fx pictures, all invisible until something puts a class on one.
   Green and orange say two different things and never compete for the same
   region: green is what's worn there, orange is where the thing in hand can
   go — and a slot that's full isn't somewhere anything can go, so the two
   are never lit at once (see renderDummyFx() in quest.js). */
.quest__dummy-fx {
  opacity: 0;
  transition: opacity 120ms ease-out;
}

/* Worn: the region stays green for as long as something is in it. */
.quest__dummy-fx--worn {
  opacity: 1;
}

/* Where the held item can go, pulsing for the length of the hold rather
   than only while a real drag is over it — the click-then-click and
   tab-then-enter paths have no "over it" moment of their own to light this
   from. */
.quest__dummy-fx--target {
  animation: quest-dummy-pulse 900ms ease-in-out infinite;
}

@keyframes quest-dummy-pulse {
  0%, 100% { opacity: 0.25; }
  50%      { opacity: 1; }
}

/* A drag actually over the region it belongs in: the pulse stops and holds
   full, so the last beat before the drop is steady rather than mid-fade.
   Only ever added to a region the item can go in — hovering anywhere else
   lights nothing, since orange means "here", not "not here". */
.quest__dummy-fx--hover {
  opacity: 1;
  animation: none;
}

.quest__dummy-fx--flash {
  opacity: 1;
  animation: quest-dummy-flash 550ms steps(1, end) both;
}

/* On, off, on, off, on — two hard blinks and a dimmed beat, landing on the
   same full green --worn takes over at, so the swap at the end of the
   animation (see DUMMY_FLASH_MS in quest.js) doesn't show. */
@keyframes quest-dummy-flash {
  0%, 22%, 44% { opacity: 1; }
  11%, 33%     { opacity: 0.12; }
  55%          { opacity: 0.45; }
  72%, 100%    { opacity: 1; }
}

/* The four hit regions, sized as percentages of the dummy's own box rather
   than in any unit tied to the viewport — see quest.js's asset-measured
   comment (there isn't one; the numbers below came from measuring the
   source art's own highlighted pixels) for where these came from. Order in
   the markup is the paint order and, for plain overlapping boxes with no
   z-index, the hit-test order too: cursor is one wide strip so both arms
   share a single button, and head/body/legs are drawn after it so their own
   narrower boxes win the pointer wherever they sit inside that strip. */
.quest__dummy-slot {
  position: absolute;
  padding: 0;
  border: 0;
  background: none;
  cursor: var(--cursor-link);
}

.quest__dummy-slot--cursor {
  left: 0;
  top: 15%;
  width: 100%;
  height: 37%;
}

.quest__dummy-slot--head {
  left: 40%;
  top: 0;
  width: 20%;
  height: 18%;
}

.quest__dummy-slot--body {
  left: 30%;
  top: 18%;
  width: 40%;
  height: 30%;
}

.quest__dummy-slot--legs {
  left: 22%;
  top: 48%;
  width: 56%;
  height: 52%;
}

.quest__dummy-slot:hover,
.quest__dummy-slot:focus-visible {
  outline: 2px dashed var(--yellow);
  outline-offset: -2px;
}

/* Where a drag in progress started, whether that's a region of the dummy or
   one of the wells beside it — the equivalent of .quest__cell--held for an
   item picked back up off the figure instead of out of the backpack. */
.quest__source {
  outline: 2px dashed var(--yellow);
  outline-offset: -2px;
}

.quest__cell img,
.quest__slot img {
  display: block;
  width: 70%;
  height: 70%;
  object-fit: contain;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

/* An empty cell is a cell; a full one is a control. */
.quest__cell--empty {
  cursor: var(--cursor-default);
}

.quest__cell--full:hover,
.quest__cell--full:focus-visible,
.quest__slot:hover,
.quest__slot:focus-visible {
  background: #2c2a48;
}

/* Held: lifted off the page, for a real drag or the click-then-click
   path alike, so touch and keyboard get the same feedback. */
.quest__cell--held {
  box-shadow: var(--raised);
  background: var(--face);
}

.quest__cell--held img {
  animation: quest-hover 1.2s ease-in-out infinite;
}

/* A well with something in it, green like its region on the figure. Both
   colours below are the art's own — the green and orange the overlay
   pictures are drawn in — so a well and the body part it stands for read
   as the same state rather than two different vocabularies. */
.quest__slot--worn {
  outline: 3px solid #6abe30;
  outline-offset: -3px;
}

/* Where a held item can go — only lit while something's in hand, and
   pulsing in time with its own region on the figure. */
.quest__slot--target {
  box-shadow: var(--raised);
  background: var(--face);
  outline: 3px solid #df7126;
  outline-offset: 3px;
  animation: quest-well-pulse 900ms ease-in-out infinite;
}

@keyframes quest-well-pulse {
  0%, 100% { outline-color: rgba(223, 113, 38, 0.3); }
  50%      { outline-color: rgba(223, 113, 38, 1); }
}

.quest__hint {
  margin: 0.9rem 0 0;
  color: var(--yellow);
  font-size: 0.8rem;
  line-height: 1.5;
}

/* The hint has nothing to say once the cursor is in its slot (see
   renderInventory() in quest.js), and an empty line's margin is still a gap
   sat above the close button. Taken out of the flow rather than left. */
.quest__hint:empty {
  display: none;
}

/* --- examining an item ----------------------------------------------------
   The card that opens beside whatever is being pointed at or tabbed to: the
   thing's own picture at 2x, its name, what it does, and where it goes.
   Drawn as a small raised window rather than a browser tooltip, because the
   inventory is a 90s window and the explanation of something inside it
   should look like it came off the same machine.

   Positioned in fixed coordinates by quest.js — left/top only, so nothing
   here has an opinion about where it lands. pointer-events: none is
   load-bearing: the card opens a few pixels from the cell the pointer is
   on, and would otherwise catch the pointer itself and fire the very
   mouseover-on-something-else that closes it, flickering on and off. */
.quest__inspect {
  --bevel: 2px;
  position: fixed;
  z-index: 47;
  display: flex;
  gap: 0.7rem;
  align-items: flex-start;
  width: max-content;
  max-width: min(20rem, calc(100vw - 1rem));
  padding: 0.7rem;
  background: var(--face);
  box-shadow: var(--raised);
  pointer-events: none;
  animation: quest-panel-fill 90ms ease-out both;
}

/* 2x the 32px source, the largest whole-pixel multiple that scales clean —
   the same bargain .quest__achv-icon makes one size up. */
.quest__inspect-sprite {
  flex: 0 0 auto;
  width: 64px;
  height: 64px;
  object-fit: contain;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

.quest__inspect-title {
  margin: 0;
  color: var(--yellow);
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}

.quest__inspect-desc {
  margin: 0.35rem 0 0;
  color: var(--white);
  font-size: 0.7rem;
  line-height: 1.5;
}

/* Where it goes, in the same small-caps the panel uses for every other
   label — it names a slot, so it is dressed like one. */
.quest__inspect-slot {
  margin: 0.4rem 0 0;
  color: var(--muted);
  font-size: 0.65rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.quest__close {
  margin-top: 0.9rem;
}

/* Mouse-only entry point. Above the ticker; the achievements button
   mirrors it on the right, with the toast popping in just above so all
   three stay clear of each other. */
.quest__bag-btn,
.quest__achievements-btn {
  --bevel: 2px;
  position: fixed;
  bottom: 3.5rem;
  z-index: 30;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.8rem;
  border: 0;
  background: var(--face);
  box-shadow: var(--raised);
  color: var(--yellow);
  font: inherit;
  font-size: 0.8rem;
  font-weight: 700;
  text-transform: uppercase;
}

.quest__bag-btn {
  left: 1rem;
}

.quest__achievements-btn {
  right: 1rem;
}

.quest__bag-btn:active,
.quest__achievements-btn:active {
  box-shadow: var(--sunken);
  translate: 1px 1px;
}

.quest__key {
  --bevel: 2px;
  --lit: var(--bg-lit);
  --dim: var(--bg-dim);
  padding: 0 0.35rem;
  background: var(--bg);
  box-shadow: var(--raised);
  color: var(--muted);
}

/* --- the moment the colour comes back ----------------------------------- */

.quest__burst {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: grid;
  place-items: center;
  overflow: hidden;
  pointer-events: none;
}

/* Out of the grid, same reason as the rays. */
.quest__flash {
  position: absolute;
  inset: 0;
  background: var(--white);
  animation: quest-flash 700ms ease-out both;
}

@keyframes quest-flash {
  0%   { opacity: 0; }
  8%   { opacity: 0.9; }
  100% { opacity: 0; }
}

/* Shockwave: a ring starting as a dot, leaving the screen. */
.quest__ring {
  grid-area: 1 / 1;
  width: 40px;
  aspect-ratio: 1;
  border: 12px solid var(--yellow);
  border-radius: 50%;
  animation: quest-ring 900ms cubic-bezier(0.1, 0.8, 0.2, 1) both;
}

@keyframes quest-ring {
  from { scale: 0; opacity: 1; }
  to   { scale: 60; opacity: 0; }
}

.quest__rays--burst {
  animation: quest-rays 3s linear infinite, quest-rays-out 1.4s ease-out both;
}

@keyframes quest-rays-out {
  0%   { scale: 0; opacity: 0.9; }
  60%  { opacity: 0.9; }
  100% { scale: 1.2; opacity: 0; }
}

/* --- the achievement ---------------------------------------------------- */

/* A shelf, not a single toast — several can queue. column-reverse stacks
   bottom-up, so the oldest stays by the button and new ones (appended
   last) land above without shoving others around. Not a click target. */
.quest__toasts {
  position: fixed;
  z-index: 46;
  right: 1rem;
  bottom: 6.5rem;
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-end;
  gap: 0.5rem;
  pointer-events: none;
}

/* One width for every trophy, not one per name. A toast is reused where it
   stands while a tally climbs (see showAchievementToast), so a width taken
   from the label meant the box shrank a little on every tick — "tourist
   3/5" is wider than "tourist". Pinned to what used to be its ceiling. */
.quest__achievement {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  width: min(22rem, calc(100vw - 2rem));
  padding: 0.75rem calc(0.9rem + var(--bevel));
  background: var(--face);
  box-shadow: var(--raised);
  border: var(--rule) solid var(--dim);
  animation: quest-achievement 500ms cubic-bezier(0.1, 0.9, 0.2, 1) both;
}

.quest__achievement img {
  width: 40px;
  height: 40px;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

.quest__achievement--leaving {
  animation: quest-achievement 400ms ease-in both reverse;
}

@keyframes quest-achievement {
  from { translate: 0 140%; opacity: 0; }
  to   { translate: 0 0; opacity: 1; }
}

.quest__achievement-kicker {
  margin: 0;
  color: var(--yellow-dark);
  font-size: 0.65rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.quest__achievement-name {
  margin: 0.2rem 0 0;
  color: var(--yellow);
  font-size: 0.9rem;
  font-weight: 700;
  text-shadow: 2px 2px 0 var(--dim);
}

/* A step toward an achievement, dialled down so the real unlock still
   lands bigger. Blacked out like the locked trophy-case icon, so the
   coloured version on the final step reads as a payoff. */
.quest__achievement--progress .quest__achievement-kicker {
  color: var(--muted);
}

.quest__achievement--progress .quest__achievement-name {
  color: var(--muted);
  text-shadow: none;
}

.quest__achievement--progress .quest__achievement-icon {
  filter: grayscale(1) brightness(0.6);
  opacity: 0.5;
}

/* Dev shortcut, not a feature — corner nobody clicks by accident,
   invisible until hovered/tabbed to. */
.quest__skip {
  position: fixed;
  right: 0.5rem;
  bottom: 0.5rem;
  z-index: 60;
  padding: 0.3rem 0.6rem;
  border: 0;
  background: var(--face);
  color: var(--muted);
  font: inherit;
  font-size: 0.7rem;
  text-transform: uppercase;
  opacity: 0;
  transition: opacity 150ms ease-out;
}

.quest__skip:hover,
.quest__skip:focus-visible {
  opacity: 1;
}

/* --- reduced motion ------------------------------------------------------
   Spin/flight/shockwave go; the states between them don't, so the
   sequence still reads. main.js types lines out instantly under the
   same preference. */
@media (prefers-reduced-motion: reduce) {
  .quest__item,
  .quest__item img,
  .icon__sprite,
  .quest__get-item,
  .quest__get-word > *,
  .quest__get-title,
  .quest__get-description,
  .quest__get-description::before,
  .quest__get-description::after,
  .quest__rays,
  .quest__flash,
  .quest__ring,
  .quest__next,
  .quest__panel--filling,
  .quest__panel--fading,
  .quest__cell--held img,
  .quest__choices .quest__btn--selected::before,
  .quest__box--shake,
  .quest__dummy-fx--target,
  .quest__slot--target,
  .quest__inspect,
  .quest__achievement {
    animation: none;
  }

  /* The pulse above is the only thing that lights this, so without it the
     region would go dark and there'd be nothing saying where the held item
     goes. Held on instead — the state, not the motion. */
  .quest__dummy-fx--target {
    opacity: 1;
  }

  .quest__panel-outline,
  .quest__achv-bar-fill {
    transition: none;
  }

  .quest__get-item {
    translate: 0 0;
  }

  .topbar,
  main,
  .ticker {
    transition: none;
  }
}
