:root {
  --world-accent: #0b5d3b;
  --pill-accent: #0b5d3b;
  --world-w1: #5b2c83;
  --world-w2: #0b5d3b;
  --world-w3: #c8791a;
  --world-w4: #a85030;
  --world-w5: #c9a66b;
  color: #172019;
  background: #f1f1ee;
  font-family: system-ui, sans-serif;

  /* Viewport-Y of the W3 "recording control" marker (.hotspot[data-world="w3"]),
     which is anchored to the viewport, not the scene still. The topbar occupies
     the top ~91px, so the minimum clears it (Pin Jun, 23 Jul: the record button
     overlapped the nav bar; 24 Jul: the redundant decorative light was removed
     so this marker is the sole record control). */
  --w3-record-left: 50.5%;
  --w3-record-top: 90.5%;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  min-height: 100%;
}

/* Keep the cinematic viewport width stable when the loader releases its
   temporary scroll lock. On browsers with a classic (non-overlay) scrollbar,
   changing html from overflow:hidden back to scrollable otherwise inserts a
   gutter after the split and nudges the landing composition left. This hides
   only the scrollbar chrome: wheel, trackpad, touch and keyboard scrolling
   continue to use the document's normal root scroller. */
html {
  scrollbar-width: none;
}

html::-webkit-scrollbar {
  display: none;
  width: 0;
  height: 0;
}

/* ---- P0 prologue (brief 08) ---------------------------------------------
   P0 is an opaque FIXED overlay, never a block in the document flow. That is
   load-bearing, not a style choice: the vendored engine maps scrollY=0 to the
   first frame of C1, so a prologue occupying real layout height would offset
   every world's scroll window and misplace every dwell, overlay and marker.
   Fixed + `pointer-events:none` also means the very first scroll gesture goes
   straight to the document, which is what makes P0 dissolve *as* the flight
   begins rather than eating a scroll.
   prologue.js writes `--p0-opacity` per frame; it never writes display. */
#prologue {
  position: fixed;
  inset: 0;
  z-index: 200; /* above the engine chrome (topbar 50) and hotspots (140/150) */
  pointer-events: none;
  opacity: var(--p0-opacity, 1);
  background:
    radial-gradient(120% 90% at 50% 34%, #fbf6ec 0%, #f5ede0 46%, #efe4d2 100%);
}

/* Once dissolved it is removed from the a11y tree and stops compositing. */
#prologue[data-p0-done="true"] {
  visibility: hidden;
}

.p0 {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}

.p0__plate {
  position: relative;
  z-index: 2;
  text-align: center;
  padding: 0 24px;
}

.p0__name {
  margin: 0;
  font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, sans-serif;
  font-weight: 700;
  font-size: clamp(2.6rem, 8vw, 6rem);
  letter-spacing: -0.02em;
  line-height: 1.02;
  color: #fff;
}

.p0__support {
  margin: 18px 0 0;
  font-size: clamp(0.95rem, 1.5vw, 1.15rem);
  letter-spacing: 0.01em;
  color: #6a6072;
}

.p0__cue {
  position: absolute;
  left: 50%;
  bottom: calc(34px + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  margin: 0;
  font-size: 0.74rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #8b8194;
}

/* The five identity words: translucent depth elements, never generated text.
   Restrained on purpose — a premium glass surface, not a product imitation. */
.p0__words {
  position: absolute;
  inset: 0;
  z-index: 1;
}

.p0__word {
  position: absolute;
  padding: 10px 18px;
  border-radius: 999px;
  font-size: clamp(0.62rem, 0.85vw, 0.78rem);
  font-weight: 600;
  letter-spacing: 0.14em;
  white-space: nowrap;
  color: color-mix(in srgb, #241d2b 62%, transparent);
  background: color-mix(in srgb, #ffffff 46%, transparent);
  border: 1px solid color-mix(in srgb, #ffffff 70%, transparent);
  box-shadow:
    0 10px 30px color-mix(in srgb, #241d2b 8%, transparent),
    inset 0 1px 0 color-mix(in srgb, #ffffff 85%, transparent);
  backdrop-filter: blur(9px) saturate(1.15);
  -webkit-backdrop-filter: blur(9px) saturate(1.15);
  /* Depth: farther words sit smaller, softer and drift a touch slower. */
  transform: scale(var(--p0-scale, 1)) translateY(var(--p0-drift, 0px));
  animation: p0-float 9s ease-in-out infinite;
  animation-delay: calc(var(--i) * -1.6s);
}

.p0__word:nth-child(1) { left: 12%; top: 26%; --p0-scale: 1.04; }
.p0__word:nth-child(2) { right: 13%; top: 33%; --p0-scale: 0.94; opacity: 0.88; }
.p0__word:nth-child(3) { left: 18%; bottom: 30%; --p0-scale: 1; }
.p0__word:nth-child(4) { right: 17%; bottom: 27%; --p0-scale: 0.9; opacity: 0.82; }
.p0__word:nth-child(5) { left: 50%; top: 15%; --p0-scale: 0.96; opacity: 0.9; }

@keyframes p0-float {
  0%, 100% { --p0-drift: 0px; transform: scale(var(--p0-scale, 1)) translateY(0); }
  50% { transform: scale(var(--p0-scale, 1)) translateY(-9px); }
}

@media (max-width: 860px) {
  /* A phone has no room for scattered words beside the name; keep the two
     that sit clear of it and drop the rest rather than crowding the plate. */
  .p0__word:nth-child(2),
  .p0__word:nth-child(4),
  .p0__word:nth-child(5) { display: none; }
  .p0__word:nth-child(1) { left: 50%; top: 18%; transform: translateX(-50%); }
  .p0__word:nth-child(3) { left: 50%; bottom: 24%; transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .p0__word { animation: none; }
}

/* P0 v48: integrate the selected V11 editorial landing treatment into the
   existing fixed prologue. The production dissolve remains owned by
   prologue.js; these rules add no second scroll loop. */
.p0 {
  display: block;
  overflow: hidden;
  isolation: isolate;
  background: #f3ebdd;
}

.p0__portrait {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%;
  filter: saturate(0.98) contrast(0.99);
}

@media (min-width: 841px) {
  /* Lower the seated portrait while keeping the warm-paper backdrop seamless.
     A modest top-anchored enlargement avoids exposing a blank strip above. */
  .p0__portrait {
    top: 0;
    right: 0;
    bottom: auto;
    left: 0;
    height: 115%;
  }
}

.p0__texture {
  position: absolute;
  inset: -18%;
  z-index: 2;
  opacity: 0.42;
  background:
    radial-gradient(ellipse 34% 23% at 22% 24%, transparent 67%, rgb(255 255 255 / 55%) 68%, transparent 69%),
    radial-gradient(ellipse 42% 29% at 78% 67%, transparent 67%, rgb(91 44 131 / 9%) 68%, transparent 69%),
    repeating-radial-gradient(ellipse at 48% 52%, transparent 0 33px, rgb(36 29 43 / 5.5%) 34px 35px, transparent 36px 56px);
  mix-blend-mode: multiply;
  pointer-events: none;
}

.p0__topbar {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: 7;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: clamp(14px, 2.4vw, 26px) clamp(18px, 5vw, 64px);
  color: #241d2b;
  font-size: 0.72rem;
  font-weight: 750;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

@media (min-width: 841px) {
  .p0__topbar {
    min-height: calc(44px + 2 * clamp(14px, 2.4vw, 26px));
  }
}

.p0__brand {
  font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, sans-serif;
  font-size: 1.1rem;
  font-weight: 700;
  letter-spacing: normal;
  text-transform: none;
}

.p0__roles {
  position: absolute;
  z-index: 6;
  top: 40%;
  width: max-content;
  max-width: 34vw;
  margin: 0;
  transform: translateY(-50%);
  color: #241d2b;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
  font-size: clamp(2.15rem, 3.45vw, 4rem);
  font-weight: 430;
  line-height: 1.08;
  letter-spacing: -0.045em;
  text-shadow: none;
}

.p0__roles { right: clamp(36px, 5vw, 76px); text-align: left; }
.p0__roles strong { color: inherit; font-weight: 650; }

.p0__name {
  position: absolute;
  z-index: 4;
  top: 60%;
  right: 0;
  left: 0;
  margin: 0;
  transform: translateY(-50%);
  color: #fff;
  display: flex;
  justify-content: center;
  font-size: clamp(76px, 15vw, 230px);
  line-height: 0.75;
  letter-spacing: -0.075em;
  text-shadow: 0 2px 20px rgb(0 0 0 / 42%);
  text-align: center;
  white-space: nowrap;
}

.p0__name span {
  display: block;
  width: max-content;
  animation: p0-name-drift 9s ease-in-out infinite alternate;
  transform: translateX(-8vw);
}

@keyframes p0-name-drift {
  from { transform: translateX(-8vw); }
  to { transform: translateX(8vw); }
}

.p0__cue {
  right: auto;
  bottom: max(22px, env(safe-area-inset-bottom));
  left: 50%;
  z-index: 8;
  display: block;
  transform: translateX(-50%);
  color: #241d2b;
  white-space: nowrap;
}

.p0__cue-wave {
  display: flex;
  align-items: baseline;
  justify-content: center;
}

.p0__cue-letter {
  display: inline-block;
  min-width: 0.24em;
  color: #241d2b;
  animation: p0-cue-wave 2.6s ease-in-out infinite;
  animation-delay: calc(var(--cue-i) * 50ms);
  will-change: transform, color;
}

.p0__cue-space {
  min-width: 0.62em;
}

/* The crest travels left to right. The white beat is more frequent now, and the
   return to the dark ink is deliberately short so the next wave does not leave
   the phrase glowing for most of its cycle. */
@keyframes p0-cue-wave {
  0%, 7% {
    color: #241d2b;
    text-shadow: none;
    transform: translateY(0);
  }
  12% {
    color: #fff;
    text-shadow: 0 2px 8px rgb(36 29 43 / 52%);
    transform: translateY(-0.55em);
  }
  18%, 42% {
    color: #fff;
    text-shadow: 0 2px 8px rgb(36 29 43 / 52%);
    transform: translateY(0);
  }
  50%, 100% {
    color: #241d2b;
    text-shadow: none;
    transform: translateY(0);
  }
}

@media (max-width: 840px) {
  /* The wordmark sat hard against the top edge (Pin Jun, 12 Aug 2026: "the top
     Ooi Pin Jun at the left hand top corner move down abit"). The safe-area
     inset is added rather than assumed: in Safari with its toolbar showing it
     is 0 and the 30px is the whole offset, while in a home-screen/fullscreen
     load it is the notch height and the wordmark must clear that too. */
  .p0__topbar { padding: calc(30px + env(safe-area-inset-top)) clamp(18px, 5vw, 64px) 14px; }
  .p0__portrait { object-position: 50% 50%; }
  .p0__roles {
    top: 31%;
    width: 132px;
    max-width: none;
    font-size: clamp(0.98rem, 3.6vw, 1.1rem);
    line-height: 1.55;
  }
  /* Roles were stopping 10px from the edge, tight against his head (measured
     on his 393px screen); 20px was then too far out, so they now sit closer to
     him ("move slightly to the left nearer to the human", 12 Aug 2026). At 32px
     the block's right edge lands ~339px in on a 393px screen and "Pharmacist"
     starts ~5px clear of his hair, which is as near as it goes without touching. */
  .p0__roles {
    right: 32px;
    text-align: right;
    text-shadow: 0 1px 10px color-mix(in srgb, var(--paper, #F5EDE0) 80%, transparent);
  }

  /* HIS NAME MUST BE READABLE IN FULL (Pin Jun, 12 Aug 2026). At 23vw it set
     ~90px on a 393px screen, making "Ooi Pin Jun" ~517px wide — wider than the
     phone, so it was ALWAYS clipped. The old glide compounded it: a marquee is
     off-screen by definition for most of its cycle, so his own name was never
     once fully visible on his own site. It is therefore sized to fit on phones;
     desktop now follows the same full-name drift treatment. */
  .p0__name {
    top: 63%;
    font-size: clamp(40px, 15vw, 112px);
    text-align: center;
  }

  /* The cue sat at #8b8194 — a pale mauve — directly over his black shirt at
     85% height, which is where the portrait is darkest. White-on-shirt was the
     first fix; he then asked for the opposite and better one (12 Aug 2026):
     "put black words, but below the hand, at the beige background so can see it".

     There IS such a band, and it is the only one. Measured off the source
     portrait: the bench seat runs bright (luma ~226) from 94% of the frame's
     height to its foot, right across the middle of the crop, while everything
     from his clasped hands down to 94% is dark trousers. That percentage is
     the same on every phone — the portrait is cover-fitted from its HEIGHT on
     any viewport narrower than 16:9, so the vertical mapping never changes with
     screen size, only the horizontal crop does.

     `max()`, not `+`, on the safe-area inset. The band is only ~6% of the frame
     tall, so ADDING a 34px notch inset to a fixed offset would push the words
     back up onto the dark trousers above it. Taking the larger of the two keeps
     them on the bench either way — 30px of clearance in Safari (where the inset
     is 0 but the bottom toolbar can overlay the foot of the page) and the real
     inset on a fullscreen load. Black ink, no wash — the band is already beige. */
  .p0__cue {
    top: auto;
    right: auto;
    bottom: max(22px, env(safe-area-inset-bottom));
    left: 50%;
    color: #241d2b;
    text-shadow: none;
    transform: translateX(-50%);
    white-space: nowrap;
  }

}

@media (prefers-reduced-motion: reduce) {
  .p0__name span { animation: none; transform: none; }
  .p0__cue-letter {
    animation: none;
    color: inherit;
    text-shadow: none;
    transform: none;
  }
}

#world {
  min-height: 100vh;
}

/* Keep the landing and world UI as two sequential states, not two translucent
   layers. W1's first frame begins near the end of P0, but its chrome/copy only
   appears once the landing is almost fully dissolved. */
html[data-p0-world-ready="false"] #world .sw-topbar,
html[data-p0-world-ready="false"] #world .sw-copylayer,
html[data-p0-world-ready="false"] #world .sw-route,
html[data-p0-world-ready="false"] #world .sw-hint {
  opacity: 0 !important;
  pointer-events: none !important;
}

/* D3: this engine-owned wrapper is the shared, full-frame media coordinate
   space. It must never be cropped, scaled or translated: later W5 overlay
   work relies on this exact untransformed stage. */
.media-stage {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  transform: none !important;
}

/* Neutral liquid-glass navigation. The world palette still belongs to the
   scene copy and artwork; changing worlds must not recolour the navigation
   shell or the route rail into five competing UI themes. Higher specificity
   deliberately overrides the vendored engine's injected styles without
   editing that locked file. */
.sw-root .sw-topbar {
  justify-content: center;
  pointer-events: none;
}

.sw-root .sw-brand {
  position: absolute;
  left: clamp(18px, 5vw, 64px);
  min-width: 44px;
  min-height: 44px;
  pointer-events: auto;
  gap: 0;
}

.sw-root .sw-brand__mark,
.sw-root .sw-particles {
  display: none;
}

.sw-root .sw-nav {
  position: relative;
  isolation: isolate;
  pointer-events: auto;
  padding: 6px;
  border: 1px solid rgb(255 255 255 / 70%);
  background:
    linear-gradient(135deg, rgb(255 255 255 / 66%), rgb(255 255 255 / 38%));
  box-shadow:
    0 18px 44px rgb(25 22 28 / 13%),
    inset 0 1px 0 rgb(255 255 255 / 88%),
    inset 0 -1px 0 rgb(255 255 255 / 28%);
  backdrop-filter: blur(20px) saturate(1.06);
  -webkit-backdrop-filter: blur(20px) saturate(1.06);
}

.sw-root .sw-nav::before {
  display: none;
}

/* Keep the copy readable without masking most of each cinematic world. The
   stock engine wash spans 58vw and begins fully opaque; this narrower,
   translucent override leaves substantially more of the scene uncovered. */
.sw-root .sw-copylayer::before {
  width: min(42vw, 600px);
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--sw-bg) 72%, transparent) 0%,
    color-mix(in srgb, var(--sw-bg) 42%, transparent) 38%,
    color-mix(in srgb, var(--sw-bg) 12%, transparent) 70%,
    transparent 100%
  );
}

/* Headlines arrive first; supporting copy follows after the camera has
   settled further into each world. */
.sw-root .sw-copy__body,
.sw-root .sw-copy__tags {
  opacity: var(--sw-copy-details, 0);
}

/* Clean neutral liquid-glass chips shared by every world. */
.sw-root .sw-copy__tags {
  gap: 10px;
}

.sw-root .sw-copy__tags li {
  padding: 8px 12px;
  border: 1px solid rgb(255 255 255 / 68%);
  border-radius: 13px;
  color: color-mix(in srgb, var(--sw-accent) 38%, #2b2730);
  background: linear-gradient(135deg, rgb(255 255 255 / 58%), rgb(255 255 255 / 30%));
  box-shadow:
    0 8px 20px rgb(25 22 28 / 8%),
    inset 0 1px 0 rgb(255 255 255 / 76%);
  backdrop-filter: blur(16px) saturate(1.04);
  -webkit-backdrop-filter: blur(16px) saturate(1.04);
}

/* W4's long eyebrow crosses both the pale desk and the dark foreground. Keep
   the shared unboxed typography, lifting only its accent toward warm paper for
   contrast over the darker parts of the scene. */
html[data-world="w4"] .sw-root .sw-copy__eyebrow {
  color: #fff8ef;
  text-shadow:
    0 2px 12px rgb(67 27 14 / 92%),
    0 1px 2px rgb(0 0 0 / 88%);
}

/* W4/W5 desktop copy crosses dark objects in both locked scenes. Keep the
   phone's per-world treatment below untouched; on desktop the headline and
   description use light ink with a compact dark halo instead of adding more
   beige wash over the artwork. Tag labels remain dark on their pale glass. */
@media (min-width: 861px) {
  /* W5's dark room needs the same unboxed accent treatment on desktop. The
     phone keeps its established per-world override in the mobile block below. */
  html[data-world="w5"] .sw-root .sw-copy__eyebrow {
    color: #fff4d6;
    text-shadow:
      0 2px 14px rgb(0 0 0 / 94%),
      0 1px 2px rgb(0 0 0 / 88%);
  }

  html[data-world="w4"] .sw-root .sw-copy__title,
  html[data-world="w5"] .sw-root .sw-copy__title {
    color: #fff;
  }

  html[data-world="w4"] .sw-root .sw-copy__body,
  html[data-world="w5"] .sw-root .sw-copy__body {
    color: rgb(255 255 255 / 90%);
  }

  html[data-world="w4"] .sw-root .sw-copy__title,
  html[data-world="w5"] .sw-root .sw-copy__title,
  html[data-world="w4"] .sw-root .sw-copy__body,
  html[data-world="w5"] .sw-root .sw-copy__body {
    text-shadow:
      0 2px 18px rgb(0 0 0 / 82%),
      0 1px 2px rgb(0 0 0 / 76%);
  }
}

.sw-root .sw-nav__item {
  color: #5f5964;
  transition: color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
}

.sw-root .sw-nav__item:hover {
  color: #211d24;
  background: rgb(255 255 255 / 42%);
}

.sw-root .sw-nav__item.is-active {
  color: #fff;
  background: rgb(34 30 37 / 88%);
  box-shadow:
    0 6px 16px rgb(26 22 29 / 18%),
    inset 0 1px 0 rgb(255 255 255 / 20%);
}

.sw-root .sw-nav__item:focus:not(:focus-visible) {
  outline: none;
}

.sw-root .sw-nav__item:focus-visible {
  outline: 2px solid rgb(45 40 49 / 62%);
  outline-offset: 2px;
}

.sw-root .sw-route {
  align-items: center;
  gap: 18px;
  padding: 18px 12px;
  border: 1px solid rgb(255 255 255 / 54%);
  border-radius: 999px;
  background: linear-gradient(160deg, rgb(255 255 255 / 48%), rgb(255 255 255 / 22%));
  box-shadow:
    0 14px 34px rgb(25 22 28 / 12%),
    inset 0 1px 0 rgb(255 255 255 / 72%);
  backdrop-filter: blur(18px) saturate(1.04);
  -webkit-backdrop-filter: blur(18px) saturate(1.04);
  transition: opacity 200ms ease, transform 200ms ease;
}

.sw-root .sw-route__dot {
  flex: 0 0 auto;
  padding: 0;
}

.sw-root .sw-route::before {
  background: rgb(44 39 48 / 28%);
  opacity: 1;
}

.sw-root .sw-route__dot i {
  background: rgb(49 44 53 / 32%);
}

.sw-root .sw-route__dot:hover i {
  background: rgb(40 35 44 / 66%);
}

.sw-root .sw-route__dot.is-active i {
  background: #2c2730;
  box-shadow: 0 0 0 5px rgb(255 255 255 / 58%);
}

.sw-root .sw-route__label {
  color: #27222b;
  border-color: rgb(255 255 255 / 66%);
  background: rgb(255 255 255 / 64%);
  box-shadow: 0 10px 24px rgb(26 22 29 / 12%);
  backdrop-filter: blur(16px) saturate(1.04);
  -webkit-backdrop-filter: blur(16px) saturate(1.04);
}

/* An open evidence deck owns the interaction. Quiet the rail so its persistent
   active label cannot peek from behind the card or compete for taps. */
#world:has(.hotspot-card) .sw-route {
  opacity: 0.26;
  pointer-events: none;
  transform: translateY(-50%) scale(0.96);
}

#world:has(.hotspot-card) .sw-route__label {
  opacity: 0 !important;
}

/* Scene overlays (brief 06).
   overlay.js writes position, size, opacity and visibility inline every
   frame — never set those here or the objects will detach from the artwork.
   The engine's own scenes top out at z-index 120. */
.sw-overlays {
  position: absolute;
  inset: 0;
  z-index: 130;
  pointer-events: none;
}

.sw-overlay {
  pointer-events: none;
}

/* The real recognition photo is intentionally softened inside the generated
   wall frame so it shares the same depth/texture as the surrounding beakers. */
.sw-overlay[data-overlay="w2-recognition-photo"] {
  filter: blur(1.25px) saturate(0.94);
}

/* W3 viewer-as-camera treatment. Pin Jun replaced the physical tripod with a
   responsive UI frame on 22 Jul 2026. It is code-native, carries no text, and
   remains visible in both landscape and portrait without moving scene art. */
.sw-overlays[data-active-world="w3"]::before {
  content: "";
  position: absolute;
  /* Top edge sits below the ~91px topbar (Pin Jun, 24 Jul: the frame must not
     overlap the top panel); the other three sides keep the original inset. */
  top: clamp(96px, 13vh, 128px);
  right: clamp(18px, 3.2vw, 38px);
  bottom: clamp(18px, 3.2vw, 38px);
  left: clamp(18px, 3.2vw, 38px);
  pointer-events: none;
  /* Viewfinder white, not the world accent (Pin Jun, 12 Aug 2026: "the 2 thing
     like the camera frame, can put it into diff colour?").

     The corners used to be drawn in `--world-w3` (#c8791a), which is the exact
     hue of the cabinet doors filling most of this scene — an orange hairline on
     orange wood at 72% opacity, so on his phone the brackets barely existed.
     Every other world's accent sits on a contrasting ground; W3's does not.

     White is also the right answer rather than merely a legible one: this frame
     is the viewer-as-camera device, and a viewfinder's corner marks are white.
     It matches W3's light copy ink, and the drop shadow carries it over the one
     pale patch in the scene (the lamp-lit wall at top right) without needing a
     second colour. `--w3-frame` so the value has one home. */
  --w3-frame: #f7f2e9;
  background:
    linear-gradient(var(--w3-frame), var(--w3-frame)) left top / 34px 2px no-repeat,
    linear-gradient(var(--w3-frame), var(--w3-frame)) left top / 2px 34px no-repeat,
    linear-gradient(var(--w3-frame), var(--w3-frame)) right top / 34px 2px no-repeat,
    linear-gradient(var(--w3-frame), var(--w3-frame)) right top / 2px 34px no-repeat,
    linear-gradient(var(--w3-frame), var(--w3-frame)) left bottom / 34px 2px no-repeat,
    linear-gradient(var(--w3-frame), var(--w3-frame)) left bottom / 2px 34px no-repeat,
    linear-gradient(var(--w3-frame), var(--w3-frame)) right bottom / 34px 2px no-repeat,
    linear-gradient(var(--w3-frame), var(--w3-frame)) right bottom / 2px 34px no-repeat;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 55%));
  opacity: 0.86;
}

/* The record light used to be a decorative ::after here AND the clickable
   marker, which appeared at different times (::after across the whole dwell,
   the marker only at the hold) and read as two overlapping buttons (Pin Jun,
   24 Jul). The ::after is removed; the single viewport-anchored marker
   (.hotspot[data-world="w3"], placed at --w3-record-top and gated to the hold)
   is now the sole recording control, appearing only once the camera lands. */

@media (prefers-reduced-motion: reduce) {
  .sw-overlay {
    transition: none;
  }
}

/* Hotspot layer (brief 07). Styled ahead of mounting so the layer is ready
   the moment the card's close-button label exists in content. Markers are
   position:fixed and centred by an inline transform — do not override
   transform, min-width or min-height (44px is the tap-target floor).

   `position` is load-bearing, not decoration: z-index is ignored on a
   static box, so without it the 140 below did nothing and every marker
   painted inside the page's base stacking context — underneath the
   engine's own fixed chrome (.sw-copylayer 20, .sw-hint 30, .sw-route 40,
   .sw-topbar 50). Markers were then unclickable wherever they fell behind
   that chrome, which is exactly what Pin Jun reported on 23 Jul. The layer
   stays pointer-transparent; hotspots.js toggles pointer-events per marker
   as each one enters and leaves its dwell. */
.hotspot-layer {
  position: fixed;
  inset: 0;
  z-index: 140;
  pointer-events: none;
}

.hotspot {
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
}

.hotspot__star {
  --hotspot-core-color: #fff;
  --hotspot-pulse-color: #fff;
  /* Two constant rings, transparent unless a world asks for them. Only W3
     does — see its rule below. Declared here (and in BOTH keyframes) so every
     box-shadow list has the same layer count and the pulse still animates. */
  --hotspot-rim: transparent;
  --hotspot-ring: transparent;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--hotspot-core-color);
  box-shadow:
    0 0 0 2px var(--hotspot-rim),
    0 0 0 4px var(--hotspot-ring),
    0 0 0 4px color-mix(in srgb, var(--hotspot-pulse-color) 36%, transparent),
    0 0 18px color-mix(in srgb, var(--hotspot-pulse-color) 72%, transparent),
    0 2px 10px rgb(0 0 0 / 38%);
  animation: hotspot-pulse 1.65s ease-in-out infinite;
}

/* ONE COLOUR PER WORLD (Pin Jun, 14 Aug 2026). Each colour was chosen against
   the pixels actually behind that world's markers, sampled from the stills over
   the marker's own footprint, in BOTH layouts. WCAG contrast of core vs that
   measured backdrop, worst case across every marker and layout:

     W1  ink #241d2b on pale wall/shirt/guitar ......... 6.7  (unchanged)
     W2  indigo #23305e on the pale lab ................ 5.6
     W3  red #e53935 — see the rim note below .......... rim carries it
     W4  white on the dark studio ...................... 8.1
     W5  sand #c9a66b on the dark room ................. 5.6

   Why these and not the world accents: W2's own accent is the pharmacy green,
   which Pin Jun rejected for markers at v111 ("another green element"), so W2
   takes an off-kit indigo — dark enough for a pale lab and clearly not W1's
   violet. W4 keeps white because its own terracotta scores 1.5 on its own
   backdrop; W5 takes its sand accent, which scores 5.6 there. */
.hotspot[data-world="w1"] .hotspot__star {
  --hotspot-core-color: #241d2b;
  --hotspot-pulse-color: #5b2c83;
}

.hotspot[data-world="w2"] .hotspot__star {
  --hotspot-core-color: #23305e;
  --hotspot-pulse-color: #2f4a8f;
}

/* W3 IS A RECORDING BUTTON (Pin Jun, 14 Aug 2026). Camera record controls read
   as a red disc inside a ring, so that is what this is: red core, a dark
   hairline for definition on pale scenes, then a light outer ring. The two
   rings are also what makes it legible at all — its control is anchored to the
   viewport at 50%/16.5% on phones, which lands on warm tan rgb(159,116,76),
   where every usable red scores 1.05-1.63. The light ring scores 4.0 there and
   19.2 on the near-black it sits on at desktop, so one treatment carries both.
   This is not the v112 dual-contrast target that was rejected: that was a
   white/black bullseye on every world; this is a record button on one. */
.hotspot[data-world="w3"] .hotspot__star {
  --hotspot-core-color: #e53935;
  --hotspot-pulse-color: #e53935;
  --hotspot-rim: rgb(18 12 8 / 90%);
  --hotspot-ring: rgb(255 255 255 / 92%);
}

/* W4 deliberately has no rule: it keeps the white default. */

.hotspot[data-world="w5"] .hotspot__star {
  --hotspot-core-color: #c9a66b;
  --hotspot-pulse-color: #c9a66b;
}

/* RECOGNITION MATCHES RESEARCH (Pin Jun, 14 Aug 2026). It carried a light
   ring at v126; he asked for it to look like the other W2 marker instead, so
   the ring is gone and both W2 markers are now plain indigo. There is no rule
   here on purpose — the per-world W2 rule above is the whole treatment.

   Known and accepted cost: this marker sits high on the framed photo, on teal
   tinsel behind silver balloon letters, where indigo measures 1.83:1 desktop /
   2.44:1 mobile with 76% / 69% of the dot camouflaged. The ring was what made
   it readable there; nothing about the core colour can. It is waived by name
   in scripts/m4_marker_contrast.py rather than by loosening the threshold, so
   every other marker is still held to 4.5:1. Restoring readability means
   moving the marker down onto the pale poster, not recolouring it. */

@keyframes hotspot-pulse {
  0%, 100% {
    box-shadow:
      0 0 0 2px var(--hotspot-rim),
      0 0 0 4px var(--hotspot-ring),
      0 0 0 4px color-mix(in srgb, var(--hotspot-pulse-color) 28%, transparent),
      0 0 10px color-mix(in srgb, var(--hotspot-pulse-color) 48%, transparent),
      0 2px 10px rgb(0 0 0 / 38%);
    transform: scale(0.88);
  }
  50% {
    box-shadow:
      0 0 0 2px var(--hotspot-rim),
      0 0 0 4px var(--hotspot-ring),
      0 0 0 14px color-mix(in srgb, var(--hotspot-pulse-color) 8%, transparent),
      0 0 34px color-mix(in srgb, var(--hotspot-pulse-color) 92%, transparent),
      0 2px 10px rgb(0 0 0 / 38%);
    transform: scale(1.22);
  }
}

/* The W3 marker is the recording control itself, not a star on a prop — so it
   is anchored to the viewport at the same Y as the decorative record light,
   NOT to a pixel on the scene still. hotspots.js recognises world w3 and skips
   its per-frame still-projection, letting these two properties place it; the
   marker keeps its JS-set `position: fixed` and `translate(-50%, -50%)`. This
   is why the light and its click target can never separate. */
.hotspot[data-world="w3"] {
  left: var(--w3-record-left);
  top: var(--w3-record-top);
}

.hotspot[data-world="w3"] .hotspot__star {
  width: 14px;
  height: 14px;
}

.hotspot:hover .hotspot__star,
.hotspot:focus-visible .hotspot__star {
  scale: 1.18;
}

.hotspot-card {
  position: fixed;
  top: 50%;
  right: clamp(74px, 6.8vw, 112px);
  left: auto;
  transform: translateY(-50%) translateX(26px) scale(0.96);
  z-index: 150;
  width: clamp(280px, 36vw, 390px);
  max-height: min(72dvh, calc(100dvh - 110px), 620px);
  overflow-x: hidden;
  overflow-y: auto;
  padding: 18px 20px;
  border: 1px solid rgb(255 255 255 / 72%);
  border-radius: 24px;
  outline: none;
  background:
    linear-gradient(145deg, rgb(255 255 255 / 76%), rgb(246 242 237 / 58%));
  color: #211d24;
  box-shadow:
    0 28px 72px rgb(21 18 24 / 24%),
    inset 0 1px 0 rgb(255 255 255 / 92%),
    inset 0 -1px 0 rgb(255 255 255 / 30%);
  backdrop-filter: blur(24px) saturate(1.05);
  -webkit-backdrop-filter: blur(24px) saturate(1.05);
  pointer-events: auto;
  opacity: 0;
  transition:
    opacity 220ms ease,
    transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1);
}

/* ---- THE HOTSPOT TYPE SCALE — EXACTLY THREE SIZES ----------------------
   Pin Jun, 12 Aug 2026, pointing at the journey card on his phone: "those
   miaomiao yogurt and bellamisu, it should fits the size of 2015-19 SMJK chung
   ling and etc. No need follow penang. Then Phd students in.... should be
   bigger but stay in a line. So it means we have 3 size for all hotspot."

   Before this, the cards had SEVEN different sizes and the journey card carried
   a private shrunken scale of its own — its title at 11.8px on a 393px phone
   against 17.3px on every other card, its links at 16px while the timeline
   entries they sit beside were 12.2px. That is the mismatch in his screenshot:
   "MiaoMiao Yogurt" was the largest text in a card whose headline was the
   smallest.

   Three tokens now, and nothing in a card may set a font-size that is not one
   of them:
     --card-t1  the card title
     --card-t2  everything that is content — body lines, link labels, item
                labels, timeline entry titles, fan captions. "SMJK Chung Ling"
                and "MiaoMiao Yogurt" are both t2, which is exactly what he
                asked for.
     --card-t3  everything that annotates content — the year ranges, "Penang",
                venue/date metadata. Deliberately NOT what the links follow.

   The journey card keeps no size overrides at all. */
.hotspot-card {
  --card-t1: 1.08rem;
  --card-t2: 0.9rem;
  --card-t3: 0.72rem;
}

.hotspot-card[data-card-id="w1-journey"] {
  width: min(860px, calc(100vw - 48px));
}

/* The credential is the W1 journey card's t1 headline and should visibly own
   the hierarchy on desktop. Raise that card's local t1 only; t2 timeline copy
   and t3 place/date metadata retain the shared scale, and the phone block keeps
   its separately measured one-line fit. */
@media (min-width: 861px) {
  .hotspot-card[data-card-id="w1-journey"] {
    --card-t1: clamp(1.22rem, 1.55vw, 1.42rem);
  }
}

.hotspot-card[data-spread-open="true"] {
  opacity: 1;
  transform: translateY(-50%) translateX(0) scale(1);
}

.hotspot-card[data-placement="anchored"] {
  right: auto;
  bottom: auto;
  transform: translateX(var(--card-enter-x, 20px)) scale(0.96);
}

.hotspot-card[data-placement="anchored"][data-spread-open="true"] {
  transform: translateX(0) scale(1);
}

/* W5 future beat: preserve the COMPLETE live W5 underneath — room, human,
   robot, copy, markers and route rail — then tint the whole viewport with one
   translucent layer. The message sits directly on that layer: no replacement
   scene, local card, glass frame or backdrop blur. */
html[data-hotspot-modal="open"],
html[data-hotspot-modal="open"] body {
  overflow: hidden;
  touch-action: none;
}

.hotspot-card.hotspot-modal {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: grid;
  place-items: center;
  width: 100vw;
  max-width: none;
  height: 100dvh;
  max-height: none;
  margin: 0;
  padding: 0;
  overflow: hidden;
  border: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  opacity: 0;
  transform: none;
  transition: opacity 360ms ease;
  isolation: isolate;
}

.hotspot-card.hotspot-modal[data-spread-open="true"] {
  opacity: 1;
  transform: none;
}

.hotspot-modal__scrim {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.hotspot-modal__scrim {
  z-index: 0;
  background:
    radial-gradient(circle at 50% 48%, rgb(104 79 91 / 12%) 0%, rgb(38 29 36 / 26%) 56%, rgb(14 11 16 / 42%) 100%),
    linear-gradient(115deg, rgb(66 48 59 / 20%), rgb(42 33 42 / 12%) 52%, rgb(25 20 27 / 26%));
  opacity: 0;
  transition: opacity 720ms ease;
}

.hotspot-card.hotspot-modal[data-spread-open="true"] .hotspot-modal__scrim {
  opacity: 1;
}

.hotspot-modal__content {
  position: relative;
  z-index: 1;
  width: fit-content;
  max-width: min(90vw, 980px);
  padding: clamp(20px, 3vw, 40px);
  opacity: 0;
  transform: translateY(28px);
  transition:
    opacity 520ms ease 140ms,
    transform 720ms cubic-bezier(0.16, 0.84, 0.22, 1) 100ms;
}

.hotspot-modal__content::after {
  position: absolute;
  right: 30%;
  bottom: 0;
  left: 30%;
  height: 1px;
  background: linear-gradient(90deg, transparent, rgb(255 248 239 / 62%), transparent);
  content: "";
  pointer-events: none;
  transform: scaleX(0);
  transform-origin: center;
}

.hotspot-card.hotspot-modal[data-spread-open="true"] .hotspot-modal__content {
  opacity: 1;
  transform: translateY(0);
}

.hotspot-card.hotspot-modal[data-spread-open="true"] .hotspot-modal__content::after {
  animation: hotspot-modal-rule 900ms 460ms cubic-bezier(0.16, 0.84, 0.22, 1) both;
}

.hotspot-modal .hotspot-card__close {
  position: fixed;
  top: max(18px, env(safe-area-inset-top));
  right: max(20px, env(safe-area-inset-right));
  z-index: 3;
}

.hotspot-modal .hotspot-card__close::before {
  color: #fff8ef;
  text-shadow: 0 2px 10px rgb(0 0 0 / 68%);
}

.hotspot-modal .hotspot-card__close:focus-visible {
  outline-color: rgb(255 248 239 / 82%);
}

.hotspot-modal .hotspot-card__title {
  max-width: 14ch;
  margin: 0;
  color: #fff8ef;
  font-size: clamp(2.6rem, 4.5vw, 4.8rem);
  font-weight: 760;
  line-height: 0.98;
  letter-spacing: -0.055em;
  text-align: center;
  text-wrap: balance;
  text-shadow: 0 3px 24px rgb(0 0 0 / 78%);
}

.hotspot-modal__eyebrow {
  margin: 0 0 clamp(16px, 2vw, 26px);
  color: rgb(255 248 239 / 76%);
  font-size: clamp(0.68rem, 0.9vw, 0.82rem);
  font-weight: 750;
  letter-spacing: 0.2em;
  text-align: center;
}

@media (max-width: 860px) {
  .hotspot-modal .hotspot-card__title {
    max-width: 11ch;
    font-size: clamp(2.3rem, 10vw, 2.7rem);
    line-height: 0.98;
  }
}

.hotspot-card.hotspot-modal[data-spread-open="true"] .hotspot-card__title {
  animation: hotspot-modal-title 780ms 180ms cubic-bezier(0.16, 0.84, 0.22, 1) both;
}

@keyframes hotspot-modal-rule {
  from { opacity: 0; transform: scaleX(0); }
  to { opacity: 1; transform: scaleX(1); }
}

@keyframes hotspot-modal-title {
  from { opacity: 0; letter-spacing: -0.12em; transform: translateY(10px) scale(0.94); }
  to { opacity: 1; letter-spacing: -0.055em; transform: translateY(0) scale(1); }
}

.hotspot-card__close {
  position: absolute;
  top: 6px;
  right: 6px;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  border: 0;
  border-radius: 0;
  appearance: none;
  display: grid;
  place-items: center;
  background: transparent;
  cursor: pointer;
  font-size: 0;
}

.hotspot-card__close:hover {
  background: transparent;
  outline: none;
}

.hotspot-card__close:focus-visible {
  background: transparent;
  outline: 2px solid rgb(45 40 49 / 62%);
  outline-offset: 1px;
}

/* The close control is rendered empty by hotspots.js on purpose: its glyph is
   decoration, and its accessible name comes from content. */
.hotspot-card__close::before {
  content: "\00d7";
  font-size: 1.4rem;
  line-height: 1;
  color: #6a6072;
}

.hotspot-card__title {
  margin: 0 44px 10px 0;
  font-size: var(--card-t1);
  font-weight: 700;
  letter-spacing: -0.01em;
}

/* The full official name is a title, so it keeps t1. It fits on one line by
   using the card's real inner width and tighter title tracking instead of
   shrinking into t2. The 34px inset clears the visible close glyph; the
   control retains its full 44x44 target above that empty corner. */
.hotspot-card[data-card-id="w1-signage"] {
  width: min(390px, calc(100vw - 24px));
}

.hotspot-card[data-card-id="w1-signage"] .hotspot-card__title {
  margin-right: 0;
  padding-right: 34px;
  line-height: 1.05;
  letter-spacing: -0.04em;
  white-space: nowrap;
}

/* The journey card sets no size of its own any more. `--card-t1` above is the
   title size for EVERY card, and this card only adds the one-line rule that its
   fixed 39-character credential string needs. */
.hotspot-card[data-card-id="w1-journey"] .hotspot-card__title {
  max-width: none;
  line-height: 1.02;
  letter-spacing: -0.035em;
  white-space: nowrap;
}

.hotspot-card__media {
  margin: 0 0 12px;
  overflow: hidden;
  border: 1px solid rgb(255 255 255 / 72%);
  border-radius: 16px;
  background: rgb(255 255 255 / 38%);
  box-shadow: 0 12px 26px rgb(24 21 27 / 12%);
}

.hotspot-card__media-image {
  display: block;
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

/* W1 journey: a real horizontal chronology instead of a vertical copy list. */
.hotspot-card__timeline {
  position: relative;
  display: flex;
  gap: 0;
  margin: 4px 0 10px;
  padding: 18px 2px 8px;
  overflow-x: auto;
  overscroll-behavior-inline: contain;
  scroll-snap-type: inline proximity;
  list-style: none;
}

.hotspot-card__timeline::before {
  content: "";
  position: absolute;
  top: 28px;
  right: 0;
  left: 0;
  height: 1px;
  background: linear-gradient(90deg, rgb(91 44 131 / 24%), rgb(91 44 131 / 72%), rgb(91 44 131 / 24%));
}

.hotspot-card__timeline-event {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  padding: 32px 10px 8px;
  text-align: center;
  scroll-snap-align: start;
}

.hotspot-card__timeline-event::before {
  content: "";
  position: absolute;
  top: 4px;
  left: 50%;
  width: 13px;
  height: 13px;
  border: 4px solid rgb(255 255 255 / 88%);
  border-radius: 50%;
  background: var(--world-w1);
  box-shadow: 0 4px 12px rgb(43 25 57 / 20%);
  transform: translateX(-50%);
}

.hotspot-card__timeline-period,
.hotspot-card__timeline-title,
.hotspot-card__timeline-detail {
  display: block;
}

.hotspot-card__timeline-period {
  margin-bottom: 5px;
  color: var(--world-w1);
  font-size: var(--card-t3);
  font-weight: 760;
  letter-spacing: 0.08em;
}

.hotspot-card__timeline-title {
  font-size: var(--card-t2);
  line-height: 1.3;
}

.hotspot-card__timeline-detail {
  margin-top: 3px;
  color: #766f79;
  font-size: var(--card-t3);
  line-height: 1.35;
}

/* Amicro-inspired arc: native links fan as soon as the hotspot card opens.
   Hover/focus only adds a small lift; touch never depends on hover. */
.hotspot-card__fan {
  position: relative;
  /* 238 -> 292. The captions moved from 0.72rem to the shared --card-t2 when the
     three-size scale came in (12 Aug 2026), and a taller line needs a taller
     box or the clamp below silently eats words. Measured on the longest reel
     title: 3 lines at 11.52px showed ~39 characters, 4 lines at 14.4px shows
     ~44, so the bigger type costs nothing in content — but only with the extra
     line and the height to draw it in. */
  height: 292px;
  margin: 2px 0 12px;
  padding: 0;
  overflow: clip;
  list-style: none;
}

.hotspot-card__fan-card {
  position: absolute;
  top: 14px;
  left: 50%;
  width: min(46%, 154px);
  transform: translateX(-50%) rotate(0deg) scale(0.9);
  transform-origin: 50% 100%;
  opacity: 0;
  transition:
    transform 520ms cubic-bezier(0.16, 0.84, 0.22, 1),
    opacity 240ms ease;
  transition-delay: calc(55ms + var(--fan-index, 0) * 65ms);
}

.hotspot-card__fan-figure {
  margin: 0;
}

.hotspot-card__fan[data-fan-count="2"] .hotspot-card__fan-card {
  width: min(53%, 176px);
}

.hotspot-card[data-spread-open="true"] .hotspot-card__fan-card {
  opacity: 1;
}

.hotspot-card[data-spread-open="true"] .hotspot-card__fan[data-fan-count="2"] .hotspot-card__fan-card:nth-child(1) {
  transform: translateX(calc(-50% - 58px)) translateY(8px) rotate(-8deg);
}

.hotspot-card[data-spread-open="true"] .hotspot-card__fan[data-fan-count="2"] .hotspot-card__fan-card:nth-child(2) {
  transform: translateX(calc(-50% + 58px)) translateY(8px) rotate(8deg);
}

.hotspot-card[data-spread-open="true"] .hotspot-card__fan[data-fan-count="3"] .hotspot-card__fan-card:nth-child(1) {
  transform: translateX(calc(-50% - 76px)) translateY(12px) rotate(-12deg);
}

.hotspot-card[data-spread-open="true"] .hotspot-card__fan[data-fan-count="3"] .hotspot-card__fan-card:nth-child(2) {
  transform: translateX(-50%) translateY(-5px) rotate(0deg) scale(1.04);
}

.hotspot-card[data-spread-open="true"] .hotspot-card__fan[data-fan-count="3"] .hotspot-card__fan-card:nth-child(3) {
  transform: translateX(calc(-50% + 76px)) translateY(12px) rotate(12deg);
}

.hotspot-card__fan-link {
  display: block;
  min-width: 44px;
  min-height: 44px;
  overflow: hidden;
  border: 1px solid rgb(255 255 255 / 78%);
  border-radius: 16px;
  background: rgb(255 255 255 / 84%);
  color: #252029;
  box-shadow: 0 14px 28px rgb(25 22 28 / 18%);
  text-decoration: none;
}

.hotspot-card__fan-image {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  background: #ece8e4;
}

.hotspot-card__fan[data-collection="reels"] .hotspot-card__fan-image {
  aspect-ratio: 4 / 5;
}

.hotspot-card__fan-caption {
  display: block;
  min-height: 96px;
  padding: 9px 10px 10px;
}

.hotspot-card__fan-label,
.hotspot-card__fan-meta {
  display: block;
}

.hotspot-card__fan-label {
  display: -webkit-box;
  overflow: hidden;
  font-size: var(--card-t2);
  line-height: 1.3;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 4;
}

.hotspot-card__fan-meta {
  margin-top: 4px;
  color: #766f79;
  font-size: var(--card-t3);
}

/* Hover/focus preview is portalled to body by hotspots.js so card overflow and
   the original fan crop cannot hide the complete source image. */
.hotspot-image-preview {
  position: fixed;
  inset: 0;
  z-index: 320;
  display: grid;
  place-items: center;
  padding: clamp(20px, 5vw, 72px);
  pointer-events: none;
  opacity: 0;
  background: rgb(24 20 27 / 34%);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  transition: opacity 220ms ease;
}

.hotspot-image-preview[data-open="true"] {
  opacity: 1;
}

.hotspot-image-preview__image {
  display: block;
  width: auto;
  height: auto;
  max-width: min(82vw, 720px);
  max-height: 76vh;
  object-fit: contain;
  border: 1px solid rgb(255 255 255 / 78%);
  border-radius: 18px;
  background: #f2eee8;
  box-shadow: 0 28px 80px rgb(15 12 17 / 42%);
  transform: translate3d(var(--preview-origin-x, 0), var(--preview-origin-y, 0), 0) scale(0.34) rotate(-2deg);
  transform-origin: 50% 50%;
  transition: transform 430ms cubic-bezier(0.18, 1.28, 0.32, 1);
  will-change: transform;
}

.hotspot-image-preview[data-open="true"] .hotspot-image-preview__image {
  transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
}

.hotspot-card__fan-card:focus-within {
  z-index: 5;
}

.hotspot-card__fan-card:focus-within .hotspot-card__fan-link {
  transform: scale(1.08);
  transform-origin: 50% 100%;
}

.hotspot-card__fan-link:focus-visible {
  outline: 3px solid color-mix(in srgb, var(--world-accent) 56%, white);
  outline-offset: 2px;
}

@media (hover: hover) {
  .hotspot-card__fan-card:hover {
    z-index: 5;
    filter: brightness(1.02);
  }

  .hotspot-card__fan-card:hover .hotspot-card__fan-link {
    transform: scale(1.08);
    transform-origin: 50% 100%;
  }

  .hotspot-card__fan-link:hover {
    outline: 3px solid color-mix(in srgb, var(--world-accent) 56%, white);
    outline-offset: 2px;
  }
}

.hotspot-card__line {
  margin: 0 0 6px;
  font-size: var(--card-t2);
  line-height: 1.5;
  color: #514b55;
}

.hotspot-card__links,
.hotspot-card__collection {
  margin: 10px 0 0;
  padding: 0;
  list-style: none;
}

/* Boxes are sized in `em`, so they follow the type (Pin Jun, 13 Aug 2026:
   "after resizing the word, the box size should change right"). He is right —
   when t2 came down to 12.5px on phones these kept their fixed 11/12px padding,
   so the shop names ended up as small type rattling around in the same large
   pill. In em the box is a constant multiple of its own text at every size and
   on every layout, and it can never drift out of step with the scale again. */
.hotspot-card__link-item,
.hotspot-card__item {
  /* The size must sit on the BOX, not only on the link inside it: `em` resolves
     against the element's own font-size, and without this the <li> was still
     16px, so 0.72em came out as 11.5px — the fixed padding all over again,
     wearing a relative unit. */
  font-size: var(--card-t2);
  padding: 0.72em 0.82em;
  border: 1px solid rgb(255 255 255 / 62%);
  border-radius: 14px;
  background: rgb(255 255 255 / 42%);
  box-shadow:
    0 8px 18px rgb(25 22 28 / 7%),
    inset 0 1px 0 rgb(255 255 255 / 68%);
}

.hotspot-card__link-item + .hotspot-card__link-item,
.hotspot-card__item + .hotspot-card__item {
  margin-top: 0.5em;
}

.hotspot-card__link-item--thumbnail {
  padding: 0.5em;
}

.hotspot-card__link--thumbnail {
  display: grid;
  grid-template-columns: 104px minmax(0, 1fr);
  align-items: center;
  gap: 12px;
}

.hotspot-card__thumbnail {
  display: block;
  width: 104px;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 10px;
  box-shadow: 0 5px 14px rgb(23 20 27 / 14%);
}

.hotspot-card__link-label {
  min-width: 0;
}

.hotspot-card__spread-item {
  opacity: 0;
  transform: translate3d(30px, -14px, 0) rotate(var(--spread-tilt, 0deg)) scale(0.94);
  transform-origin: 88% 50%;
  transition:
    opacity 260ms ease,
    transform 520ms cubic-bezier(0.16, 0.84, 0.22, 1);
  transition-delay: calc(70ms + var(--spread-index, 0) * 52ms);
}

.hotspot-card[data-spread-open="true"] .hotspot-card__spread-item {
  opacity: 1;
  transform: translate3d(0, 0, 0) rotate(0deg) scale(1);
}

/* These carried no font-size at all, so they inherited the document's 16px —
   which is why "MiaoMiao Yogurt" out-sized the timeline entry above it in his
   screenshot. They are content, so they are t2, the same as "SMJK Chung Ling". */
.hotspot-card__link,
.hotspot-card__item-link {
  color: #252029;
  font-size: var(--card-t2);
  font-weight: 620;
  text-decoration-color: rgb(37 32 41 / 34%);
  text-underline-offset: 3px;
}

.hotspot-card__link:hover,
.hotspot-card__item-link:hover,
.hotspot-card__link:focus-visible,
.hotspot-card__item-link:focus-visible {
  text-decoration-color: currentColor;
  outline: 2px solid rgb(45 40 49 / 48%);
  outline-offset: 3px;
  border-radius: 3px;
}

.hotspot-card__item {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 5px 9px;
  margin: 0;
  font-size: var(--card-t2);
}

.hotspot-card__item-meta {
  color: #766f79;
  font-size: var(--card-t3);
}

/* Separators are presentation, never authored punctuation in the data. */
.hotspot-card__item-meta[data-field="year"]::before {
  content: " · ";
}

@media (max-width: 860px) and (hover: none) and (pointer: coarse) {
  /* Keep the phone chrome light: the visible capsule is compact, while the
     route buttons retain the engine's separate touch-sized boxes. */
  .sw-root .sw-topbar {
    padding-top: calc(22px + env(safe-area-inset-top));
  }

  .sw-root .sw-route {
    right: 4px;
    gap: 8px;
    padding: 10px 4px;
    border-radius: 18px;
  }

  .sw-root .sw-route::before {
    top: 18px;
    bottom: 18px;
  }

  .sw-root .sw-route__dot i {
    width: 6px;
    height: 6px;
  }

  .sw-root .sw-route__dot.is-active i {
    box-shadow: 0 0 0 3px rgb(255 255 255 / 58%);
  }

  :root {
    /* The desktop review point (52.2%, 81.1%) falls inside the phone copy
       band. Keep the same recording control clear of copy on portrait phones.

       12 Aug 2026 — Pin Jun: "fix the W3 centre and the record button". At
       50%/63.5% the control landed on the word "myths" in the headline. It is
       now parked on the cabinet to his left: at 52% height his silhouette spans
       only x 116-275 of a 393px screen (his neck), the copy block starts ~120px
       lower once the type is at its new size, and the route rail owns the right
       edge from 330px down — so this pocket is the one clear of all three, and it
       still clears the topbar by a wide margin (the 23 Jul complaint). Moved 18%
       -> 16% when he was rescaled: his silhouette at this height now starts 13px
       further left, and left/top is the marker's CENTRE, not its corner. */
    /* 12 Aug 2026 — Pin Jun: "the recording button can put above my head". It
       had been parked on the cabinet beside his neck, which reads as a sticker
       on the furniture; over his head it reads as the camera's own record light
       and is the first thing the eye reaches after the topbar.

       16.5% is derived, not eyeballed. On a portrait phone the scene is
       cover-fitted from its HEIGHT, so every vertical position in this world is
       a fixed fraction of the viewport whatever the phone's width: his hair
       tops out at 21.8dvh on all of them. 16.5% puts the marker's centre 45px
       above that on a 852px screen and its 44px tap box 24px clear, while still
       sitting 30px INSIDE the viewfinder frame's top edge (13vh) — which is
       where a record light belongs — and far below the topbar (the 23 Jul
       complaint). Centred at 50%: his own silhouette centres on 48%. */
    --w3-record-left: 50%;
    --w3-record-top: 16.5%;
  }

  .hotspot-card {
    top: 82px;
    right: auto;
    bottom: auto;
    left: 12px;
    transform: translateY(22px) scale(0.97);
    width: calc(100vw - 24px);
    max-height: min(62dvh, 520px);
    padding: 16px;
    border-radius: 22px;
  }

  .hotspot-card[data-card-id="w1-journey"] {
    width: calc(100vw - 24px);
    max-height: calc(100dvh - 104px);
    padding: 16px;
    overflow: hidden;
  }

  /* THE SCALE ON PHONES, TAKEN STRAIGHT OFF THE JOURNEY CARD.

     Pin Jun, 12 Aug 2026: "Phd student in Universiti Sains Malaysia - title
     size 1, SMJK chung Ling - size 2, and the penang - size 3. Ya so phd should
     be bigger as the title."

     The previous pass unified the sizes but left the hierarchy inverted where it
     mattered: on his 393px screen the title landed at 14.5px and "SMJK Chung
     Ling" at 14.4px, so the heading and the body were the same size and the
     card had no top to it. (Before that pass it was worse — the title was
     11.8px against a 12.2px body, i.e. the heading was the SMALLEST text in the
     card. That is what he had been pointing at all along.)

     The ceiling is real and cannot be argued with: the credential string must
     stay on one line, which caps the title at 15.96px on a 393px phone. So the
     hierarchy has to be made underneath it, and t2/t3 come down to the values
     the journey card itself was already using and which he has never objected
     to — 0.78rem for "SMJK Chung Ling", 0.66rem for "Penang". The result is
     15.3 / 12.5 / 10.6 on his screen: a 1.23x step to the title, which is all
     the one-line rule allows, but unmistakably a heading now.

     Every card on a phone uses these. The journey card is the model for the
     scale, not an exception to it. */
  .hotspot-card {
    --card-t1: clamp(0.9rem, 3.9vw, 1rem);
    --card-t2: 0.78rem;
    --card-t3: 0.66rem;
  }

  /* "PhD student in Universiti Sains Malaysia" — the one-line constraint
     (Pin Jun, 12 Aug 2026).

     It was 11.79px on his 393px phone, and that was never a fit limit: the old
     `clamp(0.72rem, 3vw, 0.86rem)` simply resolved 3vw to 11.79 and stopped
     there. Measured on the real card, the string runs 17.81px of width per 1px
     of font size at this weight and tracking, and the title has 284px to live
     in (354px card, 16px padding each side, 38px kept clear of the close
     button). The true one-line ceiling is therefore 284/17.81 = 15.96px — the
     type had over 4px of headroom it was not using.

     3.7vw takes it to 14.5px on his screen, a 23% increase, with 1.4px still in
     hand for the font-metric differences between iOS and Android. It is capped
     at the shared `--card-t1` (1.08rem), which it reaches at ~449px wide: on
     anything larger this card's title IS the same size as every other card's,
     and the vw term only backs it off on phones too narrow to fit the line.
     That keeps the promise of three sizes and the promise of one line, in the
     only order physics allows. The floor is 0.86rem for very narrow screens.

     ⚠️ If this string ever changes length, re-measure. The whole value is
     derived from its 39 characters. */
  .hotspot-card[data-card-id="w1-journey"] .hotspot-card__title {
    margin-right: 38px;
    margin-bottom: 8px;
    line-height: 1.1;
    letter-spacing: -0.04em;
    white-space: nowrap;
  }

  .hotspot-card[data-card-id="w1-journey"] .hotspot-card__timeline {
    display: grid;
    gap: 0;
    margin: 2px 0 8px;
    padding: 4px 0 0 14px;
    overflow: visible;
    scroll-snap-type: none;
  }

  .hotspot-card[data-card-id="w1-journey"] .hotspot-card__timeline::before {
    top: 8px;
    right: auto;
    bottom: 12px;
    left: 4px;
    width: 1px;
    height: auto;
    background: linear-gradient(180deg, rgb(91 44 131 / 24%), rgb(91 44 131 / 72%), rgb(91 44 131 / 24%));
  }

  .hotspot-card__timeline-event {
    display: grid;
    /* 64px held "2015–19" at the old 0.66rem; at t3 it needs 72px or the year
       range wraps and the two-row grid collapses. */
    grid-template-columns: 72px minmax(0, 1fr);
    align-items: start;
    min-width: 0;
    padding: 0 0 7px 12px;
    text-align: left;
  }

  .hotspot-card__timeline-event::before {
    top: 3px;
    left: -10px;
    width: 9px;
    height: 9px;
    border-width: 3px;
    transform: none;
  }

  /* Sizes here are the shared tokens, not journey-card specials. The old
     0.66/0.76/0.78rem values were what made this one card read differently from
     every other one on the same phone. Only spacing is tuned below. */
  .hotspot-card__timeline-period {
    grid-column: 1;
    grid-row: 1 / span 2;
    margin: 1px 0 0;
  }

  .hotspot-card__timeline-title,
  .hotspot-card__timeline-detail {
    grid-column: 2;
  }

  .hotspot-card__timeline-title {
    line-height: 1.2;
  }

  .hotspot-card__timeline-detail {
    margin-top: 1px;
    line-height: 1.2;
  }

  .hotspot-card[data-card-id="w1-journey"] .hotspot-card__line {
    margin-bottom: 5px;
    line-height: 1.35;
  }

  .hotspot-card[data-card-id="w1-journey"] .hotspot-card__links {
    margin-top: 8px;
  }

  /* No journey-card special case: the em padding above already scales, and a
     fixed min-height was what kept these pills tall after the type shrank. */
  .hotspot-card[data-card-id="w1-journey"] .hotspot-card__link-item {
    min-height: 0;
  }

  .hotspot-card__fan {
    height: 274px;
  }

  .hotspot-card__fan-card {
    width: min(43%, 142px);
  }

  .hotspot-card__fan[data-fan-count="2"] .hotspot-card__fan-card {
    width: min(50%, 164px);
  }

  .hotspot-card[data-spread-open="true"] {
    transform: translateY(0) scale(1);
  }

  .hotspot-card__link--thumbnail {
    grid-template-columns: 92px minmax(0, 1fr);
  }

  .hotspot-card__thumbnail {
    width: 92px;
  }
}

/* W2's publication and recognition fans are complete cards, not scroll panes.
   Give them the full usable compact-viewport height (64px top clearance +
   12px foot margin) so the fixed fan, copy and links fit on a portrait phone.
   The width-only query is deliberate: it also keeps browser/device previews
   honest when their pointer reports fine instead of coarse. `auto` remains a
   last-resort safety net only for unusually short landscape viewports. */
@media (max-width: 860px) {
  .hotspot-card[data-card-id="w2-publications"],
  .hotspot-card[data-card-id="w2-recognition"] {
    max-height: calc(100dvh - 76px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .hotspot__star {
    animation: none;
    transform: none;
  }

  .hotspot-card,
  .hotspot-card__spread-item,
  .hotspot-card__fan-card {
    transition: none;
  }

  .hotspot-card.hotspot-modal,
  .hotspot-modal__scrim,
  .hotspot-modal__content {
    transition: none;
    animation: none;
  }

  .hotspot-card.hotspot-modal .hotspot-modal__content {
    opacity: 1;
    transform: none;
  }

  .hotspot-image-preview {
    transition: none;
  }

  .hotspot-image-preview__image {
    transition: none;
  }
}

/* ---- Final CTA block, world 5 (brief 08) --------------------------------
   PRD §5: "the clear right side remains reserved for the contact CTA" — the
   W5 shot was framed with that side empty for exactly this. The block is
   viewport-fixed rather than projected onto the still: its buttons must stay
   finger-reachable and legible whatever the cover-crop does to the artwork.

   It is gated by cta.js on the SAME dwell progress as the W5 markers, so it
   arrives with the held frame and, W5 being terminal, holds to the page
   bottom instead of fading into an empty room (Pin Jun, 24 Jul). */
.cta {
  position: fixed;
  /* BELOW the hotspot layer (140), above the scene overlays (130). Found by
     live hit-test on a 555px viewport, 25 Jul: at 145 the block sat over W5's
     Projects marker and the "Follow" button swallowed its tap outright — the
     marker was unreachable, not merely overlapped. The star markers are the
     scene's interactive layer and must outrank a block whose own targets are
     large; the cost is that a 44px dot may clip a corner of one button, which
     is the right way round. Do not raise this above 140. */
  z-index: 138;
  right: clamp(72px, 6.6vw, 96px); /* clear of the route rail at 14–30px */
  top: 50%;
  transform: translateY(-50%);
  width: min(430px, 34vw);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  color: #f5f1ea;
  transition: opacity 260ms ease;
}

/* cta.js sets this once the block is inside its window; without JS the block
   simply never appears, which is the correct degraded state for a page whose
   whole content is a scroll flight.

   The BLOCK stays pointer-transparent even when live — only its links take
   taps. A 430px-wide invisible rectangle that eats every pointer event over
   the scene is exactly how a marker goes missing. */
.cta[data-cta-live="true"] {
  visibility: visible;
}

.cta[data-cta-live="true"] .cta__btn {
  pointer-events: auto;
}

/* A soft dark pocket so the words stay readable over the monitor glow. */
.cta::before {
  content: "";
  position: absolute;
  inset: -46px -40px;
  z-index: -1;
  border-radius: 28px;
  background: radial-gradient(
    72% 62% at 50% 50%,
    rgb(8 10 14 / 68%) 0%,
    rgb(8 10 14 / 42%) 58%,
    rgb(8 10 14 / 0%) 100%
  );
}

/* Handwritten mark under the sign-off.

   ⚠️ HIS REAL SIGNATURE IS RULED OUT — his decision, 25 Jul ("no bake my
   signature"). The derived signature SVG is DELETED from the served folder and
   must never come back: while it existed the file answered HTTP 200 with all
   25,865 bytes even though no page displayed it, so shipping it was exposure
   regardless of how faintly it was drawn or whether any CSS pointed at it.
   Regression fails if any signature-named file reappears under `site/`. His
   original PDF lives in `../source-uploads/`, outside everything published.

   WHAT SHIPS: the approved tagline set in Caveat, which Pin Jun selected on
   25 Jul from three SIL OFL 1.1 candidates. His own handwriting was tried first
   and he rejected the look of it; that asset is kept, unused, in ../derived/.
   The font file is NOT shipped and no request goes to Google — the line was
   rendered once to a transparent mask. Licence notice in ../licences/.
   The mask is 1319x109, four times the 330 CSS px it draws at, so it stays crisp
   on any current display; if the mark is ever wanted much larger, converting
   Caveat to outlines is the upgrade path.

   It REPLACES the typeset sign-off instead of sitting beside it — same sentence,
   so showing both would print it twice. The text stays in the DOM, visually
   hidden, as the accessible name; screen readers read it and see no difference.
   Drawn as a MASK, not an <img>, so the ink takes the block's own colour: the
   artwork is black on white and would vanish on this dark scene. */
.cta__signoff-mark {
  display: none;
}

.cta[data-signoff-mark="tagline"] .cta__signoff-mark {
  display: block;
  width: min(330px, 100%);
  /* The mask's own pixel dimensions — regression asserts these stay in step. */
  aspect-ratio: 1319 / 109;
  background: currentColor;
  opacity: 0.88;
  pointer-events: none;
  -webkit-mask: url(../assets/brand/signoff-tagline.png) no-repeat left center / contain;
  mask: url(../assets/brand/signoff-tagline.png) no-repeat left center / contain;
}

/* Visually hidden, still announced. Not `display:none` and not
   `visibility:hidden` — both would remove it from the accessibility tree, and
   this text is the only thing carrying the sign-off's meaning once the
   handwriting replaces it. */
.cta[data-signoff-mark="tagline"] .cta__signoff-text {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}


/* Real text for screen readers, off-screen for everyone else. Used by the P0
   scroll cue, whose visible letters are all aria-hidden for the wave
   animation — an aria-label on that bare <p> was prohibited and ignored, so the
   cue had no accessible text at all. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.cta__overline {
  margin: 0 0 26px;
  font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, sans-serif;
  font-weight: 700;
  font-size: clamp(1.5rem, 2.4vw, 2.15rem);
  line-height: 1.1;
  letter-spacing: -0.01em;
}

.cta__actions {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
}

.cta__btn {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  min-height: 50px;
  padding: 13px 26px;
  border: 2px solid color-mix(in srgb, #f5f1ea 48%, transparent);
  border-radius: 999px;
  font-size: 0.95rem;
  font-weight: 600;
  text-decoration: none;
  color: inherit;
  transition: transform 200ms ease, background-color 200ms ease;
}

.cta__icon {
  width: 1.05em;
  height: 1.05em;
  flex: 0 0 auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.86;
}

.cta__icon-fill {
  fill: currentColor;
  stroke: none;
}

.cta__btn--primary {
  border-color: var(--world-w5);
  background: var(--world-w5);
  color: #1a1610;
}

.cta__btn:hover,
.cta__btn:focus-visible {
  transform: translateY(-2px);
}

.cta__signoff {
  position: relative;
  margin: 26px 0 0;
  font-style: italic;
  font-size: 1.05rem;
  color: color-mix(in srgb, #f5f1ea 82%, transparent);
}

@media (max-width: 860px) {
  /* Portrait: the world copy is anchored to the bottom, so the CTA takes the
     upper half. Any future mark sits inline rather than hanging below. */
  /* Vertically centred on phones (Pin Jun, 26 Jul). This frees the upper third
     of W5, where both markers now live. `dvh` matters here: with a collapsing
     mobile URL bar, `vh` is the TALLER layout viewport, so a 50% computed from
     it would push the block below true centre and into the world copy. */
  .cta {
    /* Right-aligned and narrowed on phones (Pin Jun's option 2, 26 Jul). W5's
       monitor sits in the LEFT column of the phone crop, so surrendering that
       column is what lets the Projects marker stay on the screen it points at
       while the block is centred. A full-width block cannot: measured 26 Jul,
       centring it left 27–67px of clear space, too little for a 44px marker at
       any size below his own phone, and every attempt to move the markers clear
       put them on empty background, off their anchors. */
    left: 50%;
    right: auto;
    width: min(calc(100vw - 48px), 366px);

    /* BOTTOM-ANCHORED, NOT CENTRED (Pin Jun, 12 Aug 2026: "The whole line and
       the buttons should move lower too, because it is cover the dots").

       The dots are the route rail. Measured at 393x852: the rail occupies
       x 351-389, y 329-523, and the centred block ran x 19.6-353.6, y 317-535 —
       so it overlapped the rail outright, and its soft dark pocket (`::before`,
       which bleeds 40px sideways and 46px up and down) covered the rail
       completely, dots and all. Nudging it sideways cannot fix that: the pocket
       is wider than the gap, and the block is already as narrow as the buttons
       allow.

       Vertical separation does fix it, and it is now free. The block used to be
       centred because the world copy owned the bottom of the screen — but since
       v82 W5's copy hands over the moment the CTA goes live, so the bottom band
       is empty by the time this block needs it. It therefore sits exactly where
       the world copy sat, on the same 6dvh baseline, which also puts the ending
       of the page at the bottom of the screen where an ending belongs.

       At 393x852 that lands the block at y 605-801 and its pocket top at 559 —
       36px below the rail's last dot, and 300px below the Projects marker whose
       tap target the old clamp() existed to protect. Both constraints the
       centred version was fighting are simply gone, so the clamp goes with it. */
    top: 50%;
    bottom: auto;
    transform: translate(-50%, -50%);
    text-align: center;
  }
  .cta__actions {
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    gap: 10px;
  }

  /* Smaller buttons, standard type (Pin Jun, same note: "the whatsapp me,
     email, follow jun_labb can also make it smaller the button size, and the
     font size should follw the standard").

     Only the BOX shrinks. The label is already the standard — 0.95rem is the
     15.2px he himself named as the reference scale on 12 Aug — so it is pinned
     here rather than reduced, which is what stops the box shrinking from taking
     the text with it. The chrome around it comes down: 50px tall to 42, 13/26px
     of padding to 9/17, and the border from 2px to 1.5px so it does not look
     heavy at the smaller size. 42px keeps them a comfortable thumb target while
     saving 22px of block height, which is 22px more scene. */
  .cta__btn {
    min-height: 42px;
    padding: 9px 17px;
    border-width: 1.5px;
    font-size: 0.95rem;
  }

  .cta__overline {
    margin-bottom: 20px;
  }

  .cta__signoff {
    margin-top: 20px;
  }

  .cta[data-signoff-mark="tagline"] .cta__signoff-mark {
    margin-inline: auto;
    -webkit-mask-position: center;
    mask-position: center;
  }

  /* The contact phase is an epilogue on W5's held frame. Once it starts, the
     route rail yields with the same scrubbed opacity as the world copy, leaving
     a clean centred ending rather than a faux sixth route dot beside it. */
  .sw-root .sw-route {
    transition: opacity 180ms linear;
  }

  html[data-cta-live="true"] .sw-root .sw-route {
    opacity: calc(1 - var(--cta-opacity, 1));
    pointer-events: none;
  }

  /* The pocket is a soft radial fade, so its bleed is cosmetic — trimmed on
     phones so that even at the top of its ramp it cannot reach across to the
     rail. */
  .cta::before {
    inset: -30px -22px;
  }

  /* ---- Phone copy framing (Pin Jun, 26 Jul) ------------------------------
     Two complaints from his live phone review: the copy block sat too high,
     colliding with the low scene markers (the guitar at 69.6% of the still,
     the Apple Watch at 69.1%, the journal stack at 84.7%), and its backing
     wash covered "too much beige" of the world.

     Both live in the vendored engine, which must not be edited (D7), so these
     override it from site.css. `.sw-root` raises specificity above the
     engine's own single-class rules, which would otherwise win on order —
     its <style> is injected at mount, after this sheet is linked. */

  /* Lower. The engine parks the copy at 56–110px above the bottom; that band
     is where the low markers land. Dropping it recovers roughly 30px of scene
     while keeping it clear of the home indicator via the same safe-area
     inset (now live — see viewport-fit=cover in index.html, brief 09). */
  .sw-root .sw-copy {
    bottom: calc(clamp(26px, 6dvh, 62px) + env(safe-area-inset-bottom));
  }


  /* Lighter. The engine's wash is 60% of viewport height and fully opaque at
     the foot. The world is the point of this page, so the scrim is cut to 44%
     and its opaque stop removed: it now starts at 88% ink and reaches
     transparent sooner. Text contrast is preserved where the words actually
     sit (the lower ~26%), which is all the scrim was ever for. */
  /* The topbar has NO background of its own — on desktop the copy layer's
     left-hand wash sits under it, but on phones that wash moved to the bottom,
     leaving "Ooi Pin Jun" as dark ink straight over the scene (unreadable on
     W1's roofline and W5's dark room). A soft top scrim, not a solid bar, so
     the flight still runs full-bleed under it. */
  .sw-root .sw-topbar::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background: linear-gradient(
      180deg,
      color-mix(in srgb, var(--sw-bg) 82%, transparent) 0%,
      color-mix(in srgb, var(--sw-bg) 46%, transparent) 58%,
      transparent 100%
    );
  }

  /* ---- LEGIBILITY BY INK, NOT BY WASH (Pin Jun, 12 Aug 2026) -------------
     His words: "whenever i said the word overlapped colour, it keeps adding the
     beige, transparent colour in. Could'nt we change some world's font colour to
     settle....?"

     He is right. Every previous legibility complaint had been answered by making
     the beige scrim taller and more opaque — 36% at 72% ink, then 56% at 94% —
     which buys contrast by covering the artwork the page exists to show, and has
     to grow again for every darker world. Dark ink on a dark scene is not a
     scrim problem. It is an ink problem.

     So the scrim comes back down to a light seat (46%, starting at 72%) and the
     type is recoloured per world instead. `data-world` on the document root is
     published by js/world-ink.js; the two dark worlds get light ink and a dark
     seat, the three light worlds keep dark ink and the beige one. Measured off
     the rendered phone frames: the copy band reads ~200 luma on W1/W2/W4 and
     ~85-140 on W3 (his near-black shirt fills the band once he is framed full)
     and W5 (night room). */
  .sw-root .sw-copylayer::before {
    width: 100%;
    height: 46%;
    background: linear-gradient(
      0deg,
      color-mix(in srgb, var(--sw-bg) 72%, transparent) 0%,
      color-mix(in srgb, var(--sw-bg) 56%, transparent) 30%,
      color-mix(in srgb, var(--sw-bg) 28%, transparent) 60%,
      transparent 100%
    );
  }

  /* The two dark worlds. A dark seat under light ink shows MORE of the scene
     than a beige one did under dark ink: it deepens a room that is already
     dark instead of painting a pale rectangle over it. */
  html[data-world="w3"] .sw-root .sw-copylayer::before,
  html[data-world="w5"] .sw-root .sw-copylayer::before {
    background: linear-gradient(
      0deg,
      rgb(18 14 11 / 76%) 0%,
      rgb(18 14 11 / 56%) 30%,
      rgb(18 14 11 / 26%) 60%,
      transparent 100%
    );
  }

  /* TYPE SCALE (Pin Jun, 12 Aug 2026, second pass). v81 raised the body to
     ~18px on his stated preference for something larger than the hotspot card's
     roadmap text. Seeing it on the phone he reversed that: "the font of 'where
     it begins' 'where claims get tested' and etc and their descriptions is too
     large. I want to be smaller so won't look crowded." Headline 34 -> ~26px,
     body 17.3 -> ~15.5px. A shorter block also lifts the copy's top edge clear
     of the low markers, which is the same crowding seen from the other side. */
  .sw-root .sw-copy__title {
    font-size: clamp(1.62rem, 6.6vw, 2.2rem);
    line-height: 1.08;
  }

  .sw-root .sw-copy__body {
    margin-top: 12px;
    font-size: clamp(0.96rem, 3.9vw, 1.08rem);
    line-height: 1.45;
    color: color-mix(in srgb, var(--sw-ink) 92%, transparent);
  }

  .sw-root .sw-copy__eyebrow {
    font-size: 0.76rem;
    letter-spacing: 0.1em;
    color: color-mix(in srgb, var(--sw-accent) 62%, var(--sw-ink));
  }

  .sw-root .sw-copy__tags {
    margin-top: 16px;
  }

  /* Three chips on ONE row, on every phone. This is layout, not decoration:
     W3 is the only world whose chips wrapped to a second row, and a second row
     is 39px of extra block height — which, with the copy bottom-anchored, lifted
     W3's headline 39px above every other world's. Pin Jun, 12 Aug 2026: "can we
     make W3 wording of 'Where myth get busted' and its description same at the
     same position of y-axis?" Measured at the narrowest phone in use (375px, so
     335px of usable width): its three chips need 305px at these metrics. The
     alignment therefore comes from removing the variance, not from re-anchoring
     the block — the same reason W4's body was shortened to two lines. */
  .sw-root .sw-copy__tags {
    gap: 7px;
  }

  .sw-root .sw-copy__tags li {
    padding: 6px 9px;
    font-size: 0.72rem;
  }

  /* Halos, not backgrounds: sized to soften an edge, never to build a plate. */
  .sw-root .sw-copy__eyebrow,
  .sw-root .sw-copy__title,
  .sw-root .sw-copy__body {
    text-shadow: 0 1px 10px color-mix(in srgb, var(--sw-bg) 82%, transparent);
  }

  /* ---- The dark worlds' ink ---------------------------------------------- */
  html[data-world="w3"] .sw-root .sw-copy__title,
  html[data-world="w5"] .sw-root .sw-copy__title {
    color: #f7f2e9;
  }

  html[data-world="w3"] .sw-root .sw-copy__body,
  html[data-world="w5"] .sw-root .sw-copy__body {
    color: rgb(247 242 233 / 88%);
  }

  /* The accent alone is too dark to read on its own world at these sizes, so
     the eyebrow keeps its accent hue but is lifted toward the paper. */
  html[data-world="w3"] .sw-root .sw-copy__eyebrow,
  html[data-world="w5"] .sw-root .sw-copy__eyebrow {
    color: color-mix(in srgb, var(--sw-accent) 42%, #fbf7f0);
  }

  html[data-world="w3"] .sw-root .sw-copy__eyebrow,
  html[data-world="w5"] .sw-root .sw-copy__eyebrow,
  html[data-world="w3"] .sw-root .sw-copy__title,
  html[data-world="w5"] .sw-root .sw-copy__title,
  html[data-world="w3"] .sw-root .sw-copy__body,
  html[data-world="w5"] .sw-root .sw-copy__body {
    text-shadow: 0 1px 12px rgb(0 0 0 / 62%);
  }

  /* W3's cutout foot, softened — this is what lets him be SMALLER and still sit
     high in the frame (Pin Jun, 12 Aug 2026: "W3 head make smaller").

     The geometry is a trap, and it is worth writing down. His cutout runs to its
     own bottom edge, so the box foot has to stay below the viewport foot or that
     edge draws a hard horizontal line across his chest. With the foot pinned
     there, top = viewport - height: every pixel you shrink him by drops him a
     pixel down the screen. Shrinking to a 195px head therefore parked his hair
     at 288px with ~200px of bare cabinet above it — measured, not guessed, and
     it looked like a mis-framed wide shot.

     Fading the last quarter of the box breaks that link. His shoulders dissolve
     into the copy's dark seat instead of ending at a line, so the box foot may
     sit at ~754px and he keeps the head position the composition wants. The fade
     lives entirely inside the seat, which is why it reads as the room going to
     shadow rather than as a vignette.

     Scoped to this one overlay ON PURPOSE. W5's figure is also `kind: person`
     and is flush with the viewport foot, so the same mask there would eat the
     bottom of a body that was never cut. Desktop is untouched: at 0/12/100 the
     foot is already off-screen and there is nothing to hide. */
  .sw-root .sw-overlay[data-overlay="w4-foreground"] {
    -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 78%, transparent 99%);
    mask-image: linear-gradient(180deg, #000 0%, #000 78%, transparent 99%);
  }

  .sw-root .sw-overlay[data-overlay="w3-person"] {
    -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 76%, transparent 99%);
    mask-image: linear-gradient(180deg, #000 0%, #000 76%, transparent 99%);
  }

  /* The chips are translucent white glass, which takes its colour from whatever
     is behind it: over W3's black shirt and W5's night room they turned to dark
     grey panels carrying dark accent text. Same treatment, opposite side. */
  html[data-world="w3"] .sw-root .sw-copy__tags li,
  html[data-world="w5"] .sw-root .sw-copy__tags li {
    border-color: rgb(255 255 255 / 34%);
    color: #f7f2e9;
    background: linear-gradient(135deg, rgb(255 255 255 / 22%), rgb(255 255 255 / 10%));
    box-shadow:
      0 8px 20px rgb(0 0 0 / 22%),
      inset 0 1px 0 rgb(255 255 255 / 28%);
  }

  /* W5's room is near-black behind the topbar (luma 55 measured), where the
     wordmark is fixed dark ink over whatever happens to be there. W3's wood and
     W4's grey wall are light enough for the beige top scrim to carry them; only
     W5 needs the wordmark itself to change side. */
  html[data-world="w5"] .sw-root .sw-brand,
  html[data-world="w5"] .sw-root .sw-brand__name {
    color: #f7f2e9;
  }

  html[data-world="w5"] .sw-root .sw-topbar::before {
    background: linear-gradient(
      180deg,
      rgb(18 14 11 / 82%) 0%,
      rgb(18 14 11 / 44%) 58%,
      transparent 100%
    );
  }

  /* W5 HANDOVER (Pin Jun's screenshot, 12 Aug 2026). The engine holds the
     terminal world's copy to the very end, and the CTA used to fade in at
     MARKER_WINDOW.in — fine on desktop, where the copy is left and the block is
     right, but on a phone both are bottom-anchored and every layer piled up at
     once. The world has been read by the time the CTA arrives, so it hands over
     rather than competing. `!important` because the engine writes this element's
     opacity inline on every frame.

     It is now an EXACT COMPLEMENT rather than a timed fade. The two blocks share
     one baseline since the CTA moved down off the route dots, so a 0.35s
     transition printed them over each other on a fast scroll and blanked the
     band on a slow one. `--cta-opacity` is published by cta.js on the same frame
     as the flag, so copy + CTA always sum to 1 and the exchange is scrubbed with
     the scroll like everything else on this page. The fallback of 1 matters: if
     the property is ever missing while the flag is true, the copy hides, which
     is the safe way round — the contact block is what the visitor came for. */
  html[data-cta-live="true"] .sw-root .sw-copylayer .sw-copy:nth-of-type(5) {
    opacity: calc(1 - var(--cta-opacity, 1)) !important;
    pointer-events: none;
  }

}

@media (prefers-reduced-motion: reduce) {
  .cta { transition: none; }
  .cta__btn:hover,
  .cta__btn:focus-visible { transform: none; }
}

/* ==== THE LOADING PANEL =================================================
   Pin Jun, 12 Aug 2026, with dennissnellenberg.com as the reference and one
   instruction about it: "His loading page animation not the words." So the
   MOTION is borrowed and the copy is his own.

   What is borrowed, precisely: a solid panel that owns the whole screen, one
   line of type alive in the middle of it, and an exit
   whose leading edge is CURVED rather than straight. The curve is the whole
   trick — a flat edge sliding away reads as a wipe, an arc reads as something
   opening.

   Where it departs: his exit slides the panel upward as one piece. Pin Jun
   asked for "animations spreading outward the screen", so this splits at the
   middle and the two halves leave in opposite directions, each carrying its own
   arc. The words sit in the seam, which is why they are "surrounded" until the
   moment the panel parts.

   Z-INDEX. 900 — above every layer this site has, including the hotspot cards
   at 150 and the image preview at 320. A loading panel that something can paint
   over is not a loading panel.

   The two halves are `height: 50.4%` rather than 50%: a hairline of overlap, so
   no seam shows down the middle of the screen at fractional device pixel
   ratios before the split begins. */
.loader {
  position: fixed;
  inset: 0;
  z-index: 900;
  overflow: hidden;
  pointer-events: none;
}

/* Nothing under the panel may be scrolled while it is up — a visitor who
   flicks during the load would otherwise be dropped mid-flight the moment it
   clears, which is the "pop here pop there" this site has already been fixed
   for once. Set and cleared by loader.js. */
html[data-loading="true"] {
  overflow: hidden;
  touch-action: none;
}

.loader__panel {
  position: absolute;
  right: 0;
  left: 0;
  height: 50.4%;
  /* IN THE HOUSE COLOURS, not the reference's (Pin Jun, 13 Aug 2026: "the color
     is not in our theme"). It was #141517, Snellenberg's near-black, borrowed
     along with his motion.

     #241d2b is the site's OWN ink — the exact value the topbar wordmark, the
     scroll cue and the world copy are already set in — so the panel cannot be
     off-brand. He chose it over deep green and over deep violet on 13 Aug from
     three rendered options. Two reasons it is the right one beyond taste: it is
     neutral against all five world accents, so the entrance does not commit to
     whichever world happens to come first; and it is dark, which the beige
     alternative is not — the landing is beige, so a paper-coloured panel parting
     to reveal a paper-coloured page has nothing to show. */
  background: var(--loader-ink, #241d2b);
  will-change: transform;
}

.loader__panel--top {
  top: 0;
  transform: translateY(0);
  transition: transform var(--loader-exit, 850ms) cubic-bezier(0.76, 0, 0.24, 1),
    border-radius var(--loader-exit, 850ms) cubic-bezier(0.76, 0, 0.24, 1);
}

.loader__panel--bottom {
  bottom: 0;
  transform: translateY(0);
  transition: transform var(--loader-exit, 850ms) cubic-bezier(0.76, 0, 0.24, 1),
    border-radius var(--loader-exit, 850ms) cubic-bezier(0.76, 0, 0.24, 1);
}

/* The seam. `translateY(-101%)`, not -100%: at fractional DPRs a 100% exit can
   leave a sub-pixel line of #141517 welded to the edge of the screen. */
.loader[data-loader-exit="true"] .loader__panel--top {
  border-radius: 0 0 50% 50% / 0 0 22% 22%;
  transform: translateY(-101%);
}

.loader[data-loader-exit="true"] .loader__panel--bottom {
  border-radius: 50% 50% 0 0 / 22% 22% 0 0;
  transform: translateY(101%);
}

.loader__words {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 1;
  display: block;
  margin: 0;
  transform: translate(-50%, -50%);
  color: var(--paper, #f5ede0);
  /* Exactly the t2 voice used by "SMJK Chung Ling" in the W1 journey card.
     The loader is outside a card, so it cannot inherit --card-t2 directly;
     these are the same desktop/mobile values and the same system face/weight. */
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
  font-size: 0.9rem;
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: normal;
  white-space: nowrap;
  transition: opacity 300ms ease;
}

/* The words leave BEFORE the panel parts, not with it: they belong to the
   panel, and carrying them along on a moving half would read as the type
   falling off the screen. */
.loader[data-loader-exit="true"] .loader__words {
  opacity: 0;
  transition-duration: 200ms;
}

.loader__line {
  display: block;
  animation: loader-line 360ms ease both;
}

/* No loading percentage: removed on Pin Jun's instruction, 14 Aug 2026.
   It existed only while the panel waited for the whole ~130MB set; the lead
   gate cut that to ~10s and the cycling lines carry it. */

/* Each line arrives from below and settles before the next one replaces it. */
@keyframes loader-line {
  from { opacity: 0; transform: translateY(0.42em); }
  to { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  /* The panel still covers the load; it simply does not fly apart, and the
     lines swap without travelling. */
  .loader__panel--top,
  .loader__panel--bottom {
    transition: opacity 320ms linear;
  }

  .loader[data-loader-exit="true"] .loader__panel--top,
  .loader[data-loader-exit="true"] .loader__panel--bottom {
    border-radius: 0;
    transform: none;
    opacity: 0;
  }

  .loader__line { animation: none; }
}

@media (max-width: 860px) and (hover: none) and (pointer: coarse) {
  .loader__words {
    font-size: 0.78rem;
  }
}

/* ---- World stops (Pin Jun, 23 Jul; built 25 Jul after his motion review) ---
   Invisible scroll-snap geometry so a hard fling cannot skip a world. These
   are NOT decoration and must stay zero-size: they sit inside the engine's
   scroll track purely to give the browser a snap position at each world's
   held frame. `scroll-snap-stop: always` is the load-bearing declaration —
   it forbids one fling from passing over a stop at any speed. The snap TYPE
   (`y proximity`, set on the root by world-stops.js) deliberately is not
   `mandatory`: mandatory would force the page to rest on a stop at all times
   and destroy the scrub through each fly-in.

   There are SIX of these, not five: W5 also carries an "ending" stop at 0.95,
   added 12 Aug 2026 when the contact block moved later. Proximity snapping pulls
   the scroll back from a third of a viewport away, so without a second stop the
   only frames a visitor could rest on in W5 were the arrival one and — barely —
   the page bottom. See world-stops.js for the measurement. */
.world-stops {
  /* Zero-sized origin box at the document's top-left, so each stop's `top` is
     a plain document offset. Deliberately NOT `inset: 0` — the layer must add
     no height and no hit area of its own. */
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
}

.world-stop {
  position: absolute;
  left: 0;
  width: 1px;
  height: 1px;
  pointer-events: none;
  /* `opacity: 0`, never `visibility: hidden` — a hidden box is at risk of
     being skipped as a snap area, and these boxes exist ONLY to be snap
     areas. They are 1px and transparent, so nothing is visible either way. */
  opacity: 0;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

@media (prefers-reduced-motion: reduce) {
  /* Snapping is movement the visitor did not ask for. With reduced motion the
     stills simply cross-dissolve and there is no fly-in to be flung past, so
     the stops have nothing to protect and are switched off entirely. */
  .world-stop {
    scroll-snap-align: none;
    scroll-snap-stop: normal;
  }
}

/* Phone still-motion (12 Aug 2026). Phones show stills only — both video
   approaches failed on Pin Jun's iPhone — so the movement comes from a slow
   push-in and drift mapped to his own scroll. See js/still-motion.js.

   The `!important` is load-bearing, not laziness: the engine writes
   `img.style.transform` inline on every read() for its own still scale, and
   without this the two would compound into an over-crop. Neutralising it here
   leaves exactly one author of this motion. */
@media (max-width: 860px) {
  .sw-scene__still { transform: none !important; }

  .sw-still-motion {
    position: absolute;
    inset: 0;
    overflow: hidden;
    will-change: transform;
  }
}

/* Reduced motion: hold the frame still, no push-in, no drift. */
@media (prefers-reduced-motion: reduce) {
  .sw-still-motion { transform: none !important; }
}

/* The phone's single shared clip element (12 Aug 2026). One decoder, not nine —
   see js/mobile-scrub.js. Framing copies the engine's own .sw-scene__video rules
   so the clip and the still beneath it share one crop and the handover is
   invisible. Above the scenes (10), below the copy layer (20). */
.sw-mobile-clip {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 46%;
  z-index: 15;
  opacity: 0;
  transition: opacity .2s linear;
  pointer-events: none;
}

@media (max-width: 860px) and (orientation: portrait) {
  .sw-mobile-clip { object-position: center 44%; }
}

@media (prefers-reduced-motion: reduce) {
  .sw-mobile-clip { display: none; }
}
