:root {
  --color-bg: #f9fafb;
  --color-white: #ffffff;
  --color-border: #e5e7eb;
  --color-text-900: #111827;
  --color-text-600: #4b5563;
  --color-text-400: #9ca3af;
  --color-indigo-600: #4f46e5;
  --color-indigo-50: #eef2ff;
  --color-black: #000000;
  /* Mirrors .navbar-container's own height exactly (fixed, overlays page
     content) — used as a layout buffer wherever a full-bleed section needs
     to guarantee nothing peeks out below it regardless of the GNB's
     fixed overlay. Update together if the GNB's real height ever changes. */
  --gnb-height: 57px;

  --font-heading: 'Outfit', sans-serif;
  --font-body: 'Geist', sans-serif;
  --font-pretendard: 'Pretendard', sans-serif;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  scroll-behavior: smooth;
}

html, body {
  background: var(--color-bg);
  color: var(--color-text-900);
  font-family: var(--font-body);
  min-width: 1200px;
}

/* Site-wide Korean line-break policy: wrap at word (어절) boundaries, never
   mid-syllable. :where() keeps this at zero specificity so it never
   overrides any more-specific rule already set on a heading/paragraph
   elsewhere in this file — it only fills in the baseline everywhere it
   isn't already handled. overflow-wrap:break-word is the safety net for a
   genuinely unbreakable long token (a URL, an unbroken English string)
   that would otherwise overflow its box even with keep-all in effect. */
:where(h1, h2, h3, h4, h5, h6, p) {
  word-break: keep-all;
  overflow-wrap: break-word;
  line-break: strict;
}

a {
  color: inherit;
  text-decoration: none;
}

a, button {
  cursor: pointer;
}

a:focus-visible,
button:focus-visible {
  outline: 2px solid var(--color-indigo-600);
  outline-offset: 2px;
  border-radius: 4px;
}

.container {
  width: 1200px;
  margin: 0 auto;
}

/* Shared pieces */

.badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 6px 12px;
  border-radius: 99px;
  background: var(--color-indigo-50);
  color: var(--color-indigo-600);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 12px;
  text-transform: uppercase;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 14px 24px;
  border-radius: 8px;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 15px;
  border: none;
  cursor: pointer;
  transition: background-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
    border-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
    transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
}

.btn:hover {
  transform: scale(1.02);
}

.btn:active {
  transform: scale(0.97);
}

.btn-primary {
  background: var(--color-indigo-600);
  color: var(--color-white);
}

.btn-primary:hover {
  background: #4338ca;
}

.btn-primary:active {
  background: #3730a3;
}

.btn-secondary {
  background: var(--color-white);
  color: var(--color-text-900);
  border: 1px solid var(--color-border);
}

.btn-secondary:hover {
  background: #f3f4f6;
  border-color: #d1d5db;
}

.btn-secondary:active {
  background: #e5e7eb;
}

.btn-dark {
  background: var(--color-black);
  color: var(--color-white);
}

.btn-dark:hover {
  background: #1f2937;
}

.btn-dark:active {
  background: #000000;
}

.icon-command { width: 18px; height: 18px; display: block; }
.icon-command-sm { width: 14px; height: 14px; display: block; }
.icon-arrow { width: 16px; height: 16px; display: block; }
.icon-arrow-sm { width: 14px; height: 14px; display: block; }
.icon-arrow-down { transform: rotate(90deg); }

/* Background music element is audio-only, never rendered visually. */
#bgm {
  display: none;
}

/* Intro overlay: gates the page behind a single ENTER click so Hero video
   + BGM start in sync (see script.js). White/milky translucent scrim with
   blur, never opaque — the real Hero underneath must read clearly through
   it. is-hidden fades opacity + blur back to 0 together so the reveal reads
   as one continuous dissolve rather than a hard cut. Transition timing is
   intentionally untouched by the dim/blur-strength tuning above it. */
.intro-overlay {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100dvh;
  z-index: 9999;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.4);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  transition: opacity 800ms cubic-bezier(0.16, 1, 0.3, 1),
    backdrop-filter 800ms cubic-bezier(0.16, 1, 0.3, 1),
    -webkit-backdrop-filter 800ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* ENTER 위에 아이콘이 한 줄 더 얹히므로 가로 정렬 -> 세로 정렬.
   .intro-cursor 는 position:fixed 라 이 배치에 영향받지 않는다. */
.intro-overlay {
  flex-direction: column;
  gap: 6px;
}

/* ---------- 소리 안내 아이콘 ---------- */

.intro-sound {
  display: block;
  width: 30px;
  height: 30px;
  color: var(--color-black);
  opacity: 0.55; /* ENTER 가 주인공이므로 한 단계 물러난 밝기 */
  transition: opacity 300ms ease, transform 300ms ease;
}

.intro-sound svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* 두 개의 호가 시차를 두고 바깥으로 번져나갔다 사라진다 — 스피커에서
   음파가 퍼지는 모습. transform-origin 을 스피커 쪽(왼쪽)에 두어야
   '스피커에서 나온다'는 방향이 읽힌다. */
.intro-sound-wave {
  transform-origin: 14px 12px;
  animation: introSoundWave 2s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}

.intro-sound-wave--2 {
  animation-delay: 0.28s;
}

@keyframes introSoundWave {
  0% {
    opacity: 0;
    transform: scale(0.55);
  }
  22% {
    opacity: 1;
  }
  60% {
    opacity: 0.75;
    transform: scale(1);
  }
  100% {
    opacity: 0;
    transform: scale(1.35);
  }
}

/* 스피커 본체는 음파에 맞춰 아주 미세하게만 숨 쉰다. */
.intro-sound-body {
  transform-origin: 9px 12px;
  animation: introSoundBody 2s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}

@keyframes introSoundBody {
  0%, 100% {
    transform: scale(1);
  }
  22% {
    transform: scale(1.06);
  }
}

/* ENTER 와 함께 사라진다. */
.intro-overlay.is-hidden .intro-sound {
  opacity: 0;
  transform: scale(0.98);
}

@media (prefers-reduced-motion: reduce) {
  .intro-sound-wave,
  .intro-sound-body {
    animation: none;
  }

  .intro-sound-wave {
    opacity: 1;
  }
}

.intro-overlay.is-hidden {
  opacity: 0;
  backdrop-filter: blur(0px);
  -webkit-backdrop-filter: blur(0px);
  pointer-events: none;
}

/* ENTER is text-as-button now — no box, fill, border or shadow, just the
   word itself acting as the interactive element. Font stays the site's own
   body font/weight (Geist, 600); only size is scaled up (20px × 1.3 = 26px)
   and color is pure black. Padding is kept (invisibly) purely to keep a
   generously-sized click/tap target around the text. */
.intro-enter-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 22px 72px;
  border-radius: 0;
  border: none;
  background: transparent;
  box-shadow: none;
  color: var(--color-black);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 34px;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: transform 300ms ease, letter-spacing 300ms ease, opacity 300ms ease;
}

.intro-enter-btn:hover {
  transform: scale(1.04);
  letter-spacing: 0.336em;
}

.intro-enter-btn:active {
  transform: scale(0.98);
  letter-spacing: 0.04em;
}

.intro-enter-btn:disabled {
  opacity: 0.4;
  cursor: default;
}

.intro-overlay.is-hidden .intro-enter-btn {
  opacity: 0;
  transform: scale(0.98);
}

/* Custom circular cursor, desktop-fine-pointer only. Purely decorative
   (pointer-events: none) so it can never intercept the ENTER click itself —
   that guarantee holds even if the follow logic in script.js fails, since
   this element does nothing on its own without JS moving it. cursor: none
   on the overlay is gated behind .custom-cursor-ready, a class script.js
   only adds after the follow loop is confirmed running, so a JS failure
   falls back to the normal system pointer instead of leaving none at all. */
.intro-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--color-black);
  pointer-events: none;
  z-index: 10000;
  opacity: 0;
  transform: translate3d(-50%, -50%, 0);
  transition: width 250ms cubic-bezier(0.16, 1, 0.3, 1),
    height 250ms cubic-bezier(0.16, 1, 0.3, 1),
    background-color 250ms ease, border-color 250ms ease, opacity 200ms ease;
}

.intro-cursor.is-active {
  opacity: 1;
}

.intro-cursor.is-hover {
  width: 36px;
  height: 36px;
  background: transparent;
  border: 1.5px solid var(--color-black);
}

@media (hover: hover) and (pointer: fine) {
  .intro-overlay.custom-cursor-ready {
    cursor: none;
  }
}

@media not all and (hover: hover) and (pointer: fine) {
  .intro-cursor {
    display: none;
  }
}

/* Global BGM toggle: fixed to the viewport corner regardless of scroll
   position or any section's own transforms/overflow (it's a direct body
   child, not nested in any section). Sits above normal page content but
   below the intro overlay (9999)/cursor (10000) so it can never show
   through the intro before ENTER, and stays out of the way visually —
   small, translucent, no shadow — once it is revealed. */
.bgm-toggle {
  position: fixed;
  right: 24px;
  bottom: calc(20px + env(safe-area-inset-bottom));
  z-index: 500;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid var(--color-border);
  background: rgba(255, 255, 255, 0.85);
  color: var(--color-text-900);
  cursor: pointer;
  transition: transform 220ms ease, opacity 220ms ease, background-color 220ms ease;
}

.bgm-toggle:hover {
  transform: scale(1.05);
  background: var(--color-white);
}

.bgm-toggle:active {
  transform: scale(0.97);
}

.bgm-toggle:focus-visible {
  border-radius: 50%;
}

/* Class-based visibility, not the `hidden` attribute/property — SVGElement
   doesn't implement HTMLElement's `hidden` IDL reflection, so setting
   svgEl.hidden from script silently no-ops on <svg> icons (it never
   touches the actual attribute the old `[hidden]` rule checked). .is-visible
   works uniformly on any element type. */
.bgm-icon {
  display: none;
}

.bgm-icon.is-visible {
  display: block;
}

@media (max-width: 640px) {
  .bgm-toggle {
    width: 34px;
    height: 34px;
    right: 16px;
    bottom: calc(16px + env(safe-area-inset-bottom));
  }
}

/* Scroll lock while the intro is up — keeps the Hero from being skipped
   via scroll before ENTER is clicked. */
body.intro-locked {
  overflow: hidden;
  height: 100dvh;
}

.section-header {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 16px;
  width: 100%;
}

.section-header.centered {
  align-items: center;
  text-align: center;
}

.section-title {
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: 44px;
  line-height: 1.15;
  color: var(--color-text-900);
}

.section-subtitle {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 18px;
  line-height: 1.6;
  color: var(--color-text-600);
  width: 720px;
  max-width: 100%;
}

.section-header:not(.centered) .section-subtitle {
  width: 100%;
}

/* Navbar */

/* Floating pill capsule (was a full-width bar) — same fixed element, same
   .nav-hidden/.nav-transparent state classes and the same JS driving them
   (script.js's scroll show/hide + top-of-page detection is untouched,
   this is a CSS-only redesign). Centered via left:50% + translateX(-50%),
   composed with .nav-hidden's own translateY in one shared --nav-y custom
   property so the two transforms never fight. */
.navbar-container {
  position: fixed;
  top: 20px;
  left: 50%;
  right: auto;
  z-index: 1000;
  width: fit-content;
  max-width: calc(100vw - 32px);
  --nav-y: 0px;
  background: rgba(20, 20, 20, 0.72);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 999px;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
  display: flex;
  align-items: center;
  padding: 0;
  transform: translate(-50%, var(--nav-y));
  opacity: 1;
  transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1),
    opacity 300ms cubic-bezier(0.16, 1, 0.3, 1),
    background-color 300ms ease,
    border-color 300ms ease,
    backdrop-filter 300ms ease;
}

.navbar-container.nav-hidden {
  --nav-y: -150%;
  opacity: 0;
}

/* Top-of-page overlay state — same transparent/no-blur/no-border look as
   before, now on a still-pill-shaped (border-radius untouched below)
   floating capsule instead of a full-width bar. Link colors restored
   further down exactly as before. */
.navbar-container.nav-transparent {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

.navbar {
  display: flex;
  align-items: center;
  gap: clamp(20px, 3vw, 40px);
  padding: 12px clamp(20px, 2.4vw, 28px);
}

.logo-mark {
  width: 32px;
  height: 32px;
  background: var(--color-indigo-600);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.logo-mark-sm {
  width: 24px;
  height: 24px;
  border-radius: 6px;
}

.brand-name {
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: 18px;
  color: var(--color-text-900);
}

.nav-links {
  display: flex;
  align-items: center;
  gap: 2px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 14px;
  color: rgba(255, 255, 255, 0.85);
}

.nav-links a {
  position: relative;
  padding: 8px 14px;
  border-radius: 999px;
  transition: color 150ms ease, background-color 150ms ease;
}

.nav-links a:hover {
  /* Brighter text + a barely-there background lift only — no scale, per
     spec ("과도한 확대 효과 금지"). */
  color: var(--color-white);
  background: rgba(255, 255, 255, 0.08);
}

.nav-links a:active {
  color: var(--color-white);
}

/* No existing "which section is in view" tracking exists yet to drive
   this (checked — only :hover/:active currently), so .is-active is unused
   for now; adding the style ahead of that logic (rather than building new
   JS here) is what the spec's own "기능을 새로 구현하지 말고 기존 상태
   클래스에 스타일만 연결" asks for. */
.nav-links a.is-active::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: 2px;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: currentColor;
  transform: translateX(-50%);
}

/* Over the Hero (top-of-page transparent overlay state) the menu text
   sits directly on the video background, so it needs to be white for
   real readability instead of the dark var(--color-text-600) used
   elsewhere — (0,3,0) specificity already beats the base .nav-links rule
   (0,1,0) by cascade alone, no !important needed. */
.navbar-container.nav-transparent .nav-links {
  color: #ffffff;
}

/* 호버 시 글자를 어둡게 바꾸던 규칙이 문제였다. 이 상태의 호버 배경은
   rgba(255,255,255,0.08)로 거의 투명해서, 어두운 글자가 밝은 영상 프레임
   위에 그대로 얹히면 읽히지 않았다. 글자는 흰색으로 두고 배경 쪽을
   검정으로 채워 대비를 만든다. */
.navbar-container.nav-transparent .nav-links a:hover {
  color: #ffffff;
  background: rgba(0, 0, 0, 0.72);
}

.navbar-container.nav-transparent .nav-links a:active {
  color: #ffffff;
  background: rgba(0, 0, 0, 0.85);
}

/* Small viewports: pill sits closer to the top edge and its own
   spacing/type shrink fluidly so the 3 links never force the capsule
   wider than the screen (max-width above already hard-stops it, this is
   what keeps it from needing to under the cap in the first place). */
@media (max-width: 640px) {
  .navbar-container {
    top: 12px;
    max-width: calc(100vw - 20px);
  }

  .navbar {
    gap: clamp(10px, 3vw, 20px);
    padding: 9px clamp(12px, 3vw, 18px);
  }

  .nav-links {
    gap: 0;
    font-size: clamp(11px, 3vw, 13px);
  }

  .nav-links a {
    padding: 6px 8px;
  }
}

/* Hero */

.hero-section {
  background: var(--color-bg);
  display: flex;
  align-items: center;
  justify-content: flex-start;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  padding-top: clamp(24px, 6vh, 96px);
  padding-right: 100px;
  padding-bottom: clamp(24px, 6vh, 96px);
  /* Left spacing only, reduced 20% from the original 100px (100 * 0.8 =
     80px) — top/right/bottom are untouched, and the Hero video (absolute,
     inset:0) ignores this padding entirely, so only the text/button
     content wrapper shifts left. */
  padding-left: 80px;
  position: relative;
  overflow: hidden;
}

.hero-bg-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center center;
  z-index: 0;
  pointer-events: none;
}

/* 제목이 놓이는 왼쪽만 어둡게 까는 스크림. 배경이 영상이라 프레임마다 밝기가
   달라지는데, 글자에 그림자를 주는 방식은 밝은 장면에서 부족하거나 획을
   뭉개거나 둘 중 하나가 된다. 배경 쪽에서 최저 대비를 보장하는 편이
   글자를 건드리지 않아 더 깔끔하다.
   가로 그라데이션(왼쪽 진하게 -> 오른쪽 투명)에 아주 옅은 세로 그라데이션을
   겹쳐, 최악의 경우(영상이 순백)에도 제목 영역에서 4:1 이상이 나온다.
   video(z-index:0)와 .hero-content(z-index:1) 사이에 놓는다. */
.hero-section::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    linear-gradient(
      90deg,
      rgba(0, 0, 0, 0.62) 0%,
      rgba(0, 0, 0, 0.38) 45%,
      rgba(0, 0, 0, 0) 78%
    ),
    linear-gradient(180deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.3) 100%);
}

/* 스크림만으로 충분하지만, 영상이 흰 벽처럼 균일하게 밝아지는 프레임을 위한
   보험. 반경이 넓고 아주 옅어서 획이 뭉개지지 않는 수준으로만 둔다. */
.hero-title,
.hero-subtitle {
  text-shadow: 0 1px 24px rgba(0, 0, 0, 0.35);
}

/* Scroll-down hint — shown only once the Hero video's real `ended` event
   fires (see initHeroScrollHint() in script.js), hidden again once the
   user scrolls past Hero. Hero-scoped class name, no shared/global hook,
   so it can't collide with anything else on the page. */
.hero-scroll-hint {
  position: absolute;
  left: 50%;
  bottom: clamp(28px, 4vh, 40px);
  transform: translateX(-50%);
  width: 20px;
  height: 20px;
  color: #ffffff;
  opacity: 0;
  pointer-events: none;
  z-index: 1;
  transition: opacity 600ms ease;
}

/* Icon grown 30% (20px -> 26px) independent of its container — the
   container above keeps its original 20x20 box (so position/click-area/
   bob-animation-transform are all untouched); the svg itself is centered
   on the container's own center via absolute positioning + -50%/-50%
   translate, so growing past 20px expands symmetrically around that same
   center point instead of shifting or clipping. */
.hero-scroll-hint svg {
  display: block;
  position: absolute;
  top: 50%;
  left: 50%;
  width: 26px;
  height: 26px;
  transform: translate(-50%, -50%);
}

.hero-scroll-hint.is-visible {
  opacity: 0.48;
  animation: heroScrollHintBob 1.8s ease-in-out infinite;
}

@keyframes heroScrollHintBob {
  0%,
  100% {
    transform: translateX(-50%) translateY(0);
  }
  50% {
    transform: translateX(-50%) translateY(6px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero-scroll-hint.is-visible {
    animation: none;
  }
}

.hero-content {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 24px;
  position: relative;
  z-index: 1;
  max-width: 100%;
}

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.hero-content > * {
  opacity: 0;
  animation: fadeInUp 700ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

.hero-content > *:nth-child(1) { animation-delay: 0ms; }
.hero-content > *:nth-child(2) { animation-delay: 90ms; }
.hero-content > *:nth-child(3) { animation-delay: 180ms; }

.hero-title {
  font-family: var(--font-heading);
  font-weight: 800;
  font-size: 57.6px; /* 48px * 1.2 */
  line-height: 1.1;
  color: #ffffff;
  text-align: left;
  width: 900px;
  max-width: 100%;
}

.hero-subtitle {
  font-family: var(--font-pretendard);
  font-weight: 300; /* Pretendard's static build ships this weight as a real face, not a synthetic fallback */
  font-size: 15px;
  line-height: 1.6;
  /* 60% via the color's own alpha, not the `opacity` property — .hero-
     content > * has its own fadeInUp keyframes ending at `opacity: 1`
     (animation-fill-mode: forwards), which would silently override a
     plain `opacity: 0.6` back to full once the entrance animation
     settles. Baking the 60% into color instead sidesteps that property
     entirely, so the final look holds at 60% regardless of what the
     entrance animation does to `opacity`. */
  color: rgba(255, 255, 255, 0.6);
  text-align: left;
  width: 720px;
  max-width: 100%;
}

@media (max-width: 640px) {
  .hero-section {
    padding-left: 24px;
    padding-right: 24px;
  }

  .hero-title {
    font-size: 43.2px; /* 36px * 1.2 */
  }

  .hero-subtitle {
    font-size: 14px;
  }
}

/* Scroll-distance breathing space between Hero and the Skill Transition
   scene — plain white, no divider/decoration. Height only; the section
   below handles its own scroll-triggered (not timer-triggered) reveal. */
/* Fades from pure white into the video's own sampled background gray
   (#ededed — see the sampling note on .skill-transition-section below) so
   the handoff into the video happens here, in this plain white-to-gray
   gap, rather than as a hard edge at the video rectangle's own top border. */
.hero-transition-gap {
  width: 100%;
  height: 50vh;
  background: linear-gradient(to bottom, #ffffff 0%, #ffffff 45%, #ededed 100%);
}

/* SKILL TRANSITION: scroll-triggered continuous video playback (see
   initSkillTransitionPlayback in script.js). desk02.mp4 already contains
   its own camera dolly-in (character → chair lean → monitor → screen), so
   nothing here ever scales/translates/seeks the video — the web layer's
   only job is starting native playback once, then holding the viewport
   still (via scroll-lock: wheel/touch preventDefault, not CSS
   position:sticky) until the video finishes on its own. No scroll runway
   is needed for this — the page genuinely doesn't move while locked, so
   .skill-transition-stage sits in normal flow at exactly one viewport
   height ("GNB bottom → screen bottom"), same footprint the old sticky
   used to hold.
   The section itself still fades/rises in (opacity + translateY) the
   moment it scrolls into view — unchanged from before, still a scroll-
   position IntersectionObserver trigger, never a timer.
   Background is a flat solid match for the video's own backdrop, sampled
   directly from desk02.mp4's actual pixels (corners/edges away from the
   character, desk and monitor) — not a guessed gray. 재측정(캔버스로 프레임을
   직접 읽어 가장자리 8px 띠를 평균): 클립 전체(0.05s/1.5s/3s/4.5s/5.9s)에서
   상단 띠 rgb(238,238,237), 좌 rgb(238,238,238), 우 rgb(236,236,235)로 완전히
   일정. 평균에 해당하는 #ededed 로 맞춘다. 이전 값 #ebebeb(235)는 2단계
   어두워서, object-fit:contain 이 만드는 위아래 레터박스 띠와 영상 화면
   사이에 가로선이 보였다. The
   white→gray blend now happens entirely in .hero-transition-gap above
   this section (see its own comment), so nothing changes across THIS
   section's own height — the video's rectangle edges (top/left/right/
   bottom) sit on a background that already exactly matches them, instead
   of the previous multi-stop gradient drifting underneath a now much
   shorter section (post scroll-lock rewrite, this section is one viewport
   tall, not the old ~240vh scroll runway the original stops were tuned
   for). */
/* transform dropped from this reveal (opacity-only now) — .skill-
   transition-pin > .skill-transition-stage below is now position:sticky,
   and ANY non-none transform value (including the old translateY(0)
   "identity" resting state) on an ancestor creates a new containing
   block that breaks sticky positioning for that descendant — confirmed
   gotcha, see the write-up on this exact issue in samsung-logitech.css. */
.skill-transition-section {
  position: relative;
  width: 100%;
  background: #ededed;
  opacity: 0;
  transition: opacity 550ms ease;
}

.skill-transition-section.is-revealed {
  opacity: 1;
}

/* Real scroll runway for the scrub (see initSkillTransitionPlayback in
   script.js) — a plain pin/sticky pattern, same convention as every other
   scroll-scrubbed section on this page (.feature-pin/.feature-sticky,
   etc.): a tall pin supplies real document scrollY to read progress from,
   the sticky stage holds "GNB bottom → screen bottom" while it's pinned.
   520vh = ~400vh of real video-scrub distance (MOTION_START to the
   clip's own natural end, ~5.1s of scrubbable duration — at a ~900px-tall
   viewport that's roughly 600-650px of scroll per second, inside the
   requested ~500-900px/s sensitivity band) + 100vh for the sticky stage
   itself + a 20vh post-scrub hold (HOLD_VH in the JS — reserved OUT of
   the progress denominator, same technique as everywhere else on this
   page, so progress=1 is a real short pause on the last frame, not an
   instant handoff). No overlay, no perspective zoom, no forced scroll
   realignment — the pin just releases into the plain white spacer below
   (.skill-transition-to-highlights-gap) once its own scroll runway ends. */
.skill-transition-pin {
  position: relative;
  width: 100%;
  height: 520vh;
}

.skill-transition-stage {
  position: sticky;
  top: var(--gnb-height);
  width: 100%;
  height: calc(100dvh - var(--gnb-height));
  overflow: hidden;
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  .skill-transition-pin {
    height: auto;
  }

  .skill-transition-stage {
    position: relative;
  }
}

/* width/height:100% + object-fit:contain — the frame is always shown in
   full, never cropped, regardless of viewport aspect ratio (letterboxes
   instead). No transform/scale anywhere: the camera move is baked into
   the video itself. */
.skill-transition-video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  .skill-transition-section {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* Plain document-flow spacer between the desk02 scrub and SKILL2 — no
   sticky/pin, no content, just real scroll distance. White so it reads
   as one continuous background with SKILL2's own .skill-highlights-*
   (also #ffffff) rather than a hard seam against .skill-transition-
   section's #ededed. 100vh on desktop, reduced to 70vh under 768px so
   the gap doesn't feel disproportionately long on short mobile viewports
   (spec: "모바일에서는... 60~75vh"). */
.skill-transition-to-highlights-gap {
  width: 100%;
  height: 100vh;
  background: #ffffff;
}

@media (max-width: 768px) {
  .skill-transition-to-highlights-gap {
    height: 70vh;
  }
}

/* ---------- history-intro: cinematic scroll story + History itself ----------
   Same pin/sticky pattern as every other pinned section on this page
   (.feature-pin/.feature-sticky) — a tall pin gives the sticky stage a
   long scroll runway, and initHistoryIntro()
   in script.js drives every visual (typography scale/opacity, the giant
   "24" mask, all 24 real History-card clones' gather/stack/spread
   transforms) as a pure continuous function of scroll progress through
   that runway. Nothing here is a one-shot/autoplay animation or an
   IntersectionObserver trigger, so scrolling back up reverses the entire
   sequence exactly. transform/opacity only — no top/left/width/height
   animation — so 24 simultaneously-moving cards stay cheap to composite. */
/* Same #000000 as .history-intro-sticky's own background, reused here so
   the strip above the sticky (screenY 0 -> --gnb-height, which the sticky
   element's own box never covers — it starts AT --gnb-height) is never
   left showing body's near-white background through. That strip is only
   ever normally hidden behind the fixed GNB bar; the moment the GNB
   auto-hides mid-scroll (nav-hidden: translateY(-100%) + opacity:0) it
   would otherwise flash white for as long as this section is in view. No
   fixed-height overlay div needed — the section's own background is the
   real fix, correct for the section's full scroll runway automatically. */
.history-intro-section {
  position: relative;
  background: #000000;
  /* GNB "About" link (#about) lands here — same offset the GNB uses
     everywhere else, so its own top content never sits under the fixed
     bar. */
  scroll-margin-top: var(--gnb-height);
}

.history-intro-pin {
  position: relative;
  height: 900vh;
}

.history-intro-sticky {
  position: sticky;
  top: var(--gnb-height);
  height: calc(100dvh - var(--gnb-height));
  overflow: hidden;
  background: #000000;
}

/* Separate from .history-intro-sticky so the "camera push" (a subtle
   whole-scene scale pulse during the spread scene) can transform just the
   content, never the sticky positioning context itself. */
.history-intro-stage {
  position: relative;
  width: 100%;
  height: 100%;
  will-change: transform;
}

.history-intro-copy {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}

.history-intro-headline {
  position: relative;
  width: 100%;
  height: 100%;
}

/* Both headline states stack in the same spot and crossfade purely via
   opacity (continuous, scroll-driven) — never a DOM text rewrite, so
   there's no layout jump between "12 YEARS" and "24 PROJECTS". Typography
   is intentionally huge and allowed to crop against .history-intro-sticky's
   own overflow:hidden per the "full-screen, crop is fine" direction. */
.history-intro-state {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  margin: 0;
  white-space: nowrap;
  opacity: 0;
  will-change: transform, opacity;
}

.history-intro-num {
  display: inline-block;
  font-family: var(--font-heading);
  font-weight: 800;
  font-size: clamp(140px, 21vw, 440px);
  line-height: 1;
  letter-spacing: -0.02em;
  color: #ffffff;
  will-change: transform, opacity, max-width, margin;
}

.history-intro-unit {
  display: inline-block;
  overflow: hidden;
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: clamp(30px, 3.6vw, 66px);
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.68);
  vertical-align: middle;
  margin-left: clamp(12px, 1.2vw, 24px);
}

.history-intro-state--projects .history-intro-num {
  font-size: clamp(120px, 18.5vw, 388px); /* PROJECTS is a longer word than YEARS — number eases down slightly so the line keeps reading as one word-group before the giant handoff */
}

/* Giant "24" mask/window — sits exactly where .history-intro-projects-num
   is, and JS crossfades opacity from one to the other at the instant they
   match in size, so the swap is invisible. From there this element keeps
   scaling up alone (its sibling headline is gone), acting as a mask over
   a fast horizontal strip of real History-card clones so cards are
   visible "inside" the glyph shape. */
.history-intro-giant {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 2;
  width: clamp(280px, 30vw, 560px);
  height: clamp(180px, 19vw, 360px);
  transform: translate(-50%, -50%) scale(1);
  opacity: 0;
  pointer-events: none;
  will-change: transform, opacity;
}

.history-intro-giant-mask {
  width: 100%;
  height: 100%;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%20300%20180'%3E%3Ctext%20x%3D'150'%20y%3D'140'%20font-family%3D'Arial%2C%20Helvetica%2C%20sans-serif'%20font-weight%3D'900'%20font-size%3D'190'%20text-anchor%3D'middle'%3E24%3C%2Ftext%3E%3C%2Fsvg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%20300%20180'%3E%3Ctext%20x%3D'150'%20y%3D'140'%20font-family%3D'Arial%2C%20Helvetica%2C%20sans-serif'%20font-weight%3D'900'%20font-size%3D'190'%20text-anchor%3D'middle'%3E24%3C%2Ftext%3E%3C%2Fsvg%3E");
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  overflow: hidden;
  background: #0a0a0a;
}

.history-intro-giant-track {
  display: flex;
  align-items: center;
  gap: 14px;
  height: 100%;
  width: max-content;
  transform-origin: left center;
  will-change: transform;
}

/* The 24 real History-card clones (built via the shared buildHistoryCard()
   used by History itself) that gather into a stack, hold, then explode
   into a spread that lines up with History's own two-row layout. Every
   per-card transform below is written directly to card.style.transform by
   initHistoryIntro()'s render(t) — nothing here positions them; this only
   holds the shared visual (identical to .history-card, scoped once). */
.history-intro-cards {
  position: absolute;
  inset: 0;
  z-index: 1;
}

.history-intro-cards .history-card {
  position: absolute;
  top: 50%;
  left: 50%;
  margin: -107.5px 0 0 -142.5px; /* half of .history-card's 285x215 base size */
  opacity: 0;
  will-change: transform, opacity;
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  .history-intro-state,
  .history-intro-giant,
  .history-intro-cards .history-card {
    transition: none;
  }
}

@media (max-width: 900px) {
  .history-intro-pin {
    height: 600vh; /* shorter runway on small screens — same sequence, simplified pacing, never a stronger/longer version than desktop */
  }

  .history-intro-cards .history-card {
    margin: -95px 0 0 -110px; /* half of the 220x190 mobile .history-card size */
  }
}

.history-card {
  background: #ffffff;
  width: 285px;
  height: 215px;
  border-radius: 12px;
  padding: 23px 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  overflow: hidden;
  flex-shrink: 0;
}

.history-card-top {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

.history-card-logo {
  width: 93px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

.history-card-logo img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

.history-card-text {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 9px;
}

.history-card-title {
  font-family: var(--font-pretendard);
  font-weight: 700;
  font-size: 20px;
  line-height: 1.2;
  color: #000000;
  text-align: center;
  width: 261px;
  margin: 0;
}

.history-card-meta {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

.history-card-date {
  display: flex;
  align-items: center;
  gap: 4px;
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: 12px;
  color: #888888;
  white-space: nowrap;
}

.history-card-divider {
  width: 1px;
  height: 10px;
  background: #cccccc;
  flex-shrink: 0;
}

.history-card-duration {
  font-family: var(--font-pretendard);
  font-weight: 600;
  font-size: 12px;
  color: #222222;
  white-space: nowrap;
}

.history-card-label {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 3px 5px;
  border-radius: 3px;
  font-family: var(--font-pretendard);
  font-weight: 600;
  font-size: 12px;
  white-space: nowrap;
  flex-shrink: 0;
}

.history-card-label--pl {
  background: #000000;
  color: #ffffff;
}

.history-card-label--pa {
  background: #d5d5d5;
  color: #4b4b4b;
}

@media (max-width: 900px) {
  .history-card {
    width: 220px;
    height: 190px;
  }

  .history-card-title {
    width: 196px;
    font-size: 16px;
  }
}

/* Home keyword parallax section — see the HTML comment above
   .home-keyword-section for the interaction reference. White, with dark
   type — deliberately breaks from both black neighbors (history-intro
   above, .explore-section below); that color seam at both boundaries is
   expected/accepted for this section's own editorial-light look. No
   GSAP/ScrollTrigger anywhere on this site (confirmed before building
   this) — same native pin/sticky + single rAF render loop convention as
   every other scroll-scrubbed section here. All motion is driven every
   frame from a single 0-1 scroll progress value by
   initHomeKeywordSection() in script.js — nothing here animates via CSS
   transition/keyframes on its own. No eyebrow/counter, no shared "window"
   container, no blurred-title-as-image trick — 4 independent boxes, a
   real (temporarily reused project) photo in each, and a plain legible
   title, nothing else. */
.home-keyword-section {
  position: relative;
  width: 100%;
  background: #ffffff;
  /* Contains every mix-blend-mode effect used inside this section (the
     center title blending against the images below it) so it can never
     bleed into the GNB or any other section. */
  isolation: isolate;
  /* Plain, non-pinned breathing room before the pin engages and after it
     releases — a normal-flow spacer outside .home-keyword-pin, so it
     never touches pin height or the scroll-progress math (getProgress()
     re-measures the pin's real position every time, it never assumes an
     offset), it just changes where that pin happens to sit on the page. */
  padding-block: clamp(180px, 22vh, 320px);
}

.home-keyword-pin {
  position: relative;
  width: 100%;
  height: 450vh; /* JS may override this inline once it measures the real gap-per-stage ratio, see measureSpacing() */
}

.home-keyword-sticky {
  position: sticky;
  top: 0;
  height: 100svh;
  min-height: 100vh;
  width: 100%;
  overflow: hidden;
  background: #ffffff;
  /* Vertical breathing room stays as before; horizontal margin is the
     single source of truth for how close to the edge the images sit —
     .home-keyword-content below no longer double-constrains this with
     its own max-width. Kept in sync with the same clamp() used as the
     "spread toward edge" term in .home-keyword-step--left/--right below. */
  padding-block: clamp(72px, 12vh, 160px);
  padding-inline: clamp(16px, 3vw, 64px);
  box-sizing: border-box;
}

/* Purely a positioning reference for the 4 independent step boxes below
   — it never has a background, border, transform, or z-index of its own
   (z-index would create a stacking context here and wall the images off
   from the center title's mix-blend-mode, which needs to see them as
   backdrop), so it can never itself read as a connecting "container".
   position: relative (not absolute/inset: 0) is deliberate: an
   absolutely positioned box's containing block is the PARENT'S PADDING
   BOX, so it would span straight through .home-keyword-sticky's
   padding-inline and ignore it. Relative positioning keeps this in
   normal flow, sized against the parent's CONTENT box instead — the
   box that actually excludes the padding — which is what makes
   padding-inline the real, single source of truth for the edge gap. */
.home-keyword-content {
  position: relative;
  width: 100%;
  height: 100%;
}

/* Each project step is its OWN element — own background (its image),
   own size, own position, own transform — never a child sliding inside
   one shared clipping window (that was the previous bug: identical
   size/position/background for every image made all 4 read as one
   connected vertical strip). JS only ever writes THIS element's own
   transform every frame; nothing here is animated via a parent. */
.home-keyword-step {
  position: absolute;
  top: 50%;
  width: clamp(520px, 42vw, 760px);
  /* Same width-drives-the-ratio technique as before — capping height via
     a plain max-height fights aspect-ratio silently, so the cap lives on
     width instead, keeping the true 4:5 ratio (now just the media-wrap's
     ratio) at any viewport height. Divided by 1.08 (the media-wrap's own
     108%-of-this-width sizing below) so the actual rendered image height
     stays at the original safe budget — this cap was tuned back when the
     media-wrap was still 100% of this width; left at 78vh after the
     media-wrap grew to 108%, the real image got 8% taller than budgeted,
     which is what was pushing stage 0's caption/title above
     .home-keyword-sticky's own top edge and clipping it there. */
  max-width: calc((78vh / 1.08) * 4 / 5);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  transform: translateY(-50%); /* JS overwrites every frame: translateY(calc(-50% + offset*100%)) */
  opacity: 0;
  z-index: 1;
  will-change: transform, opacity;
}

/* Caption sits above the image, inside the SAME independent step box, so
   it enters/exits with its own image as one unit — never a shared/global
   caption area. Left edge matches the image's own left edge exactly
   (both are width:100% of this flex column), never exceeding image width. */
.home-keyword-step-caption {
  width: 100%;
  margin-bottom: clamp(20px, 2vw, 32px);
}

.home-keyword-step-title {
  margin: 0;
  font-weight: 700;
  font-size: clamp(28px, 2.2vw, 36px);
  line-height: 1.3;
  color: #111111;
}

.home-keyword-step-desc {
  margin: clamp(7px, 0.6vw, 10px) 0 0;
  font-weight: 400;
  font-size: clamp(16px, 1.1vw, 20px);
  line-height: 1.5;
  color: #777777;
}

/* Now an <a> (the whole frame is the click target to its project's
   detail page) — display:block so it fills exactly the same box an
   unstyled div would, keeping width/aspect-ratio/position untouched.
   overflow:hidden is what lets the inner image scale up on hover
   without ever pushing this frame's own size/position. */
.home-keyword-step-media-wrap {
  position: relative; /* containing block for .home-keyword-cursor-dot below */
  display: block;
  width: 100%;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: 0;
  cursor: pointer; /* fallback for touch/coarse-pointer devices, which never get the custom dot */
}

/* Frame + image at 108% of the pre-enlargement size (120% × 0.9 — a 10%
   reduction off the previous 120% step, still aspect-ratio-locked so
   height scales with width, no distortion). Grown direction-aware so the
   screen-edge-anchored side of each frame never moves: left-aligned
   frames grow rightward (toward center) off their own left edge, which is
   unaffected here (the parent's align-items: flex-start already anchors
   it); right-aligned frames use align-self: flex-end to anchor their
   RIGHT edge instead and grow leftward — margin-left: auto was tried
   first but resolves to 0 once the child is wider than its container
   (auto margins never go negative), so it silently grew rightward too
   instead of anchoring the right edge. */
.home-keyword-step--left .home-keyword-step-media-wrap {
  width: 108%;
}

.home-keyword-step--right .home-keyword-step-media-wrap {
  width: 108%;
  align-self: flex-end;
}

/* Right-aligned cards only: the caption must widen/anchor exactly like
   its own media-wrap above (same width, same align-self: flex-end) so
   both share the same RIGHT edge — since they're then equal-width, their
   LEFT edges land on the exact same x automatically. Without this, caption
   stayed at the column's default 100%/flex-start while the enlarged image
   grew further left past it, reading as the text being indented inward
   from the image's real left edge. Left-aligned cards need no equivalent
   rule: both already share the same flex-start left edge. */
.home-keyword-step--right .home-keyword-step-caption {
  width: 108%;
  align-self: flex-end;
}

.home-keyword-step-media-wrap:focus-visible {
  outline: 2px solid #111111;
  outline-offset: 3px;
}

/* Custom hover cursor — only ever shown on real mouse/trackpad input
   (JS gates attaching the enter/move/leave listeners on the same
   hover:hover + pointer:fine check, so it's inert on touch regardless of
   this rule). Native cursor is hidden here so this dot reads as the only
   pointer, rather than a forced-arrow system cursor. */
@media (hover: hover) and (pointer: fine) {
  .home-keyword-step-media-wrap {
    cursor: none;
  }
}

.home-keyword-cursor-dot {
  position: absolute;
  top: 0;
  left: 0;
  width: 52px;
  height: 52px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(17, 17, 17, 0.55);
  color: #ffffff;
  font-size: 18px;
  line-height: 1;
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.6);
  transition: opacity 220ms ease, transform 220ms ease;
  will-change: transform, opacity;
}

.home-keyword-cursor-dot.is-active {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce), (hover: none), (pointer: coarse) {
  .home-keyword-cursor-dot {
    display: none;
  }
}

/* Alternating left/right per stage (1 SOL페이-left, 2 땡겨요-right,
   3 삼성전자-left, 4 BMW-right). Plain edge anchor: distance from
   .home-keyword-content's own edge, kept identical to .home-keyword-sticky's
   padding-inline above — this clamp() is the single, direct gutter lever
   for how close to the screen edge the images sit; no other rule pulls
   them back inward, so reducing it here (and above, in sync) is
   guaranteed to move both sides outward by exactly that amount. */
.home-keyword-step--left {
  left: clamp(16px, 3vw, 64px);
  right: auto;
}

.home-keyword-step--right {
  right: clamp(16px, 3vw, 64px);
  left: auto;
}

/* Real project photo, temporarily reused from PROJECTS in script.js
   (spec: "기존 프로젝트 대표 이미지가 있으면 임시로 사용") — object-fit:cover
   by default so the portrait frame is always filled with no letterboxing;
   object-position pulled toward the top since app/UI screenshots keep
   their key content there. The one stage without a real photo yet
   (image: null) falls back to a plainly-labeled neutral placeholder — no
   gray "box" chrome, no border, just a flat fill + centered label so it
   reads unambiguously as "not a real image" rather than as a design
   element. cover is swapped for contain per-stage (fit: 'contain') for
   any asset that isn't a full-bleed photo (e.g. a transparent device
   mockup), so it's never stretched or cropped into its own padding. */
.home-keyword-step-media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
  border-radius: 0;
  background: #f4f4f4;
  transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* Only the image scales on hover/keyboard-focus — never the frame around
   it (that stays exactly the size/position the scroll motion already set
   on .home-keyword-step; only this img gets the transform). */
.home-keyword-step-media-wrap:hover .home-keyword-step-media,
.home-keyword-step-media-wrap:focus-visible .home-keyword-step-media {
  transform: scale(1.1);
}

.home-keyword-step-media--contain {
  object-fit: contain;
}

@media (prefers-reduced-motion: reduce) {
  .home-keyword-step-media {
    transition: none;
  }
}

.home-keyword-step-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f4f4f4;
  color: #999999;
  font-size: clamp(13px, 1vw, 15px);
  font-weight: 500;
  text-align: center;
  padding: 24px;
  box-sizing: border-box;
}

/* Text layer: dead-center, fixed for the entire section. No transform and
   no z-index here (either would create a stacking context that walls the
   title off from the images, breaking .home-keyword-title's
   mix-blend-mode below) — it just fills the same box as
   .home-keyword-content; DOM order alone already paints it after/above
   the images, and z-index lives on the title itself. */
.home-keyword-text-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  text-align: center;
}

/* Fills the text-layer box and centers its own text via flex instead of
   a transform, so all 4 stages overlap at the exact same point without
   a transform anywhere in this chain (a transform on an ancestor would
   isolate it the same way z-index does). mix-blend-mode: difference +
   a white base color is the Kakao-style effect: black/dark on the plain
   white section background, inverted wherever the text crosses an
   image — z-index: 2 (> .home-keyword-step's 1) keeps it literally above
   the images in the same shared stacking context established by
   .home-keyword-section's isolation: isolate. */
.home-keyword-title {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2;
  margin: 0;
  font-weight: 800;
  font-size: clamp(73.6px, 10.35vw, 193.2px); /* base × 1.15 */
  line-height: 1;
  letter-spacing: -0.02em;
  white-space: nowrap;
  color: #ffffff;
  mix-blend-mode: difference;
  opacity: 0;
  filter: blur(10px);
  will-change: opacity, filter;
}

/* Works(프로젝트) 섹션만은 모바일에서도 데스크톱과 같은 핀 동작을 쓴다.
   이 섹션의 진행은 passive scroll 리스너 하나로만 계산되고 휠/터치를
   가로채지 않아, 모바일에서도 그대로 동작한다(다른 섹션들을 막았던 이유가
   여기엔 없다). 따라서 정적 대체는 모션 최소화 설정에만 적용한다. */
@media (prefers-reduced-motion: reduce) {
  .home-keyword-pin {
    height: auto;
  }

  .home-keyword-sticky {
    position: relative;
    height: auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: clamp(32px, 6vw, 64px);
  }

  .home-keyword-content {
    position: relative;
    inset: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: clamp(32px, 6vw, 64px);
    width: 100%;
    max-width: none;
  }

  /* Static fallback: the 4 stages show in order, no filmstrip motion. */
  .home-keyword-step {
    position: relative;
    top: auto;
    left: auto !important;
    right: auto !important;
    transform: none !important;
    opacity: 1;
    width: min(480px, 80vw, calc(60vh * 4 / 5));
  }

  .home-keyword-text-layer {
    position: relative;
    top: auto;
    left: auto;
    transform: none;
  }

  .home-keyword-title {
    position: relative;
    top: auto;
    left: auto;
    transform: none;
    opacity: 1;
    filter: none;
  }
}

@media (max-width: 900px) {
  .home-keyword-step {
    width: clamp(280px, 58vw, 480px);
    max-width: calc((58vh / 1.08) * 4 / 5); /* same /1.08 correction as the base rule */
  }

  .home-keyword-title {
    font-size: clamp(55.2px, 14.95vw, 138px); /* base × 1.15 */
  }
}

@media (max-width: 600px) {
  /* Smaller still. left/right stay on the base rule's plain edge anchor
     (clamp() there already scales down at narrow viewports via its vw
     term), so no separate position override is needed here. */
  .home-keyword-step {
    width: clamp(160px, 62vw, 260px);
    max-width: calc((46vh / 1.08) * 4 / 5); /* same /1.08 correction as the base rule */
  }

  .home-keyword-title {
    font-size: clamp(41.4px, 17.25vw, 101.2px); /* base × 1.15 */
  }
}

/* Project detail — 신한 NEW 슈퍼SOL 통합 개발 (test build, card-1 only) */

/* Structure for every block in this page: .project-detail-section (full
   viewport width — owns the solid background color and any absolutely
   positioned decorative layer) > optional .project-detail-background
   (absolute, inset:0, full-bleed decorative visual, sits behind content)
   + .project-detail-inner (the only element with a max-width, centered via
   margin:auto — actual text/content goes here, nothing else). Background
   color/gradient/decorative imagery must never be placed on
   .project-detail-inner or on anything constrained by it. */

.project-detail-section {
  position: relative;
  width: 100%;
  background: #0a0d18;
  padding-top: clamp(64px, 10dvh, 140px);
  /* No overflow here — overflow on ANY ancestor of a position:sticky
     element (like .glass-reveal-sticky below) disables its stickiness
     entirely. All full-bleed children already use width:100% rather than
     a negative-margin escape trick, so there's nothing here that needs
     clipping. */
}

.project-detail-background {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
}

/* The only element allowed a max-width anywhere in this page. Width caps
   at 1440px; below that, padding-inline (not the width formula) supplies
   the side gutter so it scales fluidly instead of jumping between fixed
   breakpoints. Never move background/gradient/decorative rules onto this
   or anything it contains — those stay on .project-detail-section /
   .project-detail-background so they keep spanning full viewport width. */
.project-detail-inner {
  position: relative;
  z-index: 1;
  width: min(1440px, 100%);
  margin-inline: auto;
  padding-inline: clamp(24px, 4vw, 64px);
}

.detail-full-bleed {
  width: 100%;
}

.detail-full-bleed img,
.detail-full-bleed video {
  display: block;
  width: 100%;
  height: auto;
}

/* "전 화면 Dark Mode 대응" — a light band (breaks from the section's dark
   navy background on purpose, matching the current Figma frame) holding a
   title pill on a dashed rail, then a vertical-scroll-driven horizontal
   slide of light/dark mode screen pairs. Card images keep their own
   intrinsic aspect ratio via a shared height + auto width, overriding the
   .detail-full-bleed img rule above (which this block also carries, for
   the same full-width-escape layout it already provides). */
.darkmode-slide-block {
  background: #f4f5f7;
  padding-block: clamp(48px, 8dvh, 96px);
}

.darkmode-slide-title-row {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  gap: 20px;
  width: min(820px, 100%);
  margin: 0 auto;
  padding-inline: 24px;
}

.darkmode-slide-line {
  flex: 1;
  height: 0;
  border-top: 1px dashed rgba(17, 24, 39, 0.3);
}

.darkmode-slide-title {
  flex-shrink: 0;
  font-family: var(--font-pretendard);
  font-weight: 700;
  font-size: clamp(18px, 2vw, 24px);
  color: var(--color-text-900);
  white-space: nowrap;
  padding: 12px 28px;
  border: 1px dashed rgba(17, 24, 39, 0.3);
  border-radius: 9999px;
}

.darkmode-slide-pin {
  position: relative;
}

/* Column layout so the title stays stacked above the track *within* the
   same sticky element — once the sticky engages, both move together and
   the title is still on screen for the entire horizontal-scroll phase,
   instead of having already scrolled past before the slide starts. */
.darkmode-slide-sticky {
  position: sticky;
  top: 57px;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(24px, 5dvh, 48px);
  padding-block: clamp(16px, 3dvh, 32px);
  overflow: hidden;
  box-sizing: border-box;
}

.darkmode-slide-track {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  height: clamp(280px, 56dvh, 480px);
  gap: 24px;
  padding-left: clamp(24px, 6vw, 80px);
  padding-right: clamp(80px, 10vw, 160px);
  box-sizing: border-box;
  will-change: transform;
}

.darkmode-slide-card {
  flex-shrink: 0;
  height: 100%;
  display: flex;
}

.darkmode-slide-block .darkmode-slide-card img {
  display: block;
  width: auto;
  height: 100%;
  border-radius: 20px;
  box-shadow: 0 16px 40px rgba(17, 24, 39, 0.14);
}

@media (prefers-reduced-motion: reduce) {
  .darkmode-slide-pin {
    height: auto !important;
  }

  .darkmode-slide-sticky {
    position: static;
    width: 100%;
    height: auto;
  }

  .darkmode-slide-track {
    overflow-x: auto;
    transform: none !important;
    padding-bottom: 8px;
  }
}

.detail-hero-section {
  position: relative;
  width: 100%;
  overflow: hidden;
}

.detail-hero-glow {
  background: radial-gradient(1100px 800px at 80% 45%, rgba(37, 52, 110, 0.55), transparent 65%);
}

.detail-hero {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 48px;
  padding-bottom: clamp(64px, 10dvh, 140px);
}

.detail-hero-text {
  flex: 1 1 460px;
  min-width: 0;
  max-width: 560px;
  padding-left: 120px;
}

.detail-brand {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 28px;
}

.detail-brand img {
  width: 26px;
  height: 26px;
  border-radius: 4px;
}

.detail-brand span {
  font-family: var(--font-pretendard);
  font-weight: 700;
  font-size: 18px;
  color: #0046ff;
}

.detail-title {
  font-family: var(--font-pretendard);
  font-weight: 800;
  font-size: clamp(26px, 3vw, 40px);
  line-height: 1.25;
  letter-spacing: -1px;
  color: var(--color-white);
  margin-bottom: 20px;
}

.detail-desc {
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: 15px;
  line-height: 1.7;
  color: rgba(255, 255, 255, 0.55);
  margin-bottom: 48px;
}

.detail-desc strong {
  font-weight: 700;
  color: rgba(255, 255, 255, 0.92);
}

.detail-meta-group {
  margin-bottom: 32px;
}

.detail-meta-heading {
  font-family: var(--font-pretendard);
  font-weight: 700;
  font-size: 15px;
  color: #0046ff;
  margin-bottom: 16px;
}

.detail-meta-row {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px 0;
}

.detail-meta-label {
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: 13px;
  color: rgba(255, 255, 255, 0.45);
}

.detail-meta-value {
  font-family: var(--font-pretendard);
  font-weight: 700;
  font-size: 17px;
  color: var(--color-white);
}

.detail-meta-pill {
  display: inline-block;
  width: fit-content;
  padding: 4px 14px;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 6px;
  font-size: 14px;
}

.detail-hero-visual {
  flex: 1 1 560px;
  min-width: 0;
  display: flex;
  justify-content: center;
}

/* Every UI-screenshot image in this page (hero visual, .detail-visual,
   .detail-visual-tight) is real content, never
   a decorative crop-to-fill background — so it must never be cropped.
   width/height:auto + max-width/max-height together shrink the image
   proportionally on BOTH axes (never distorting, never cutting off top or
   bottom) so a short laptop viewport scales the whole composition down
   instead of clipping it; a tall viewport just shows it at full size. */
.detail-hero-visual img,
.detail-hero-visual video {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: min(620px, 60dvh);
  /* This crop carries its own baked-in dark/glow background from the
     source Figma frame, which never matches the section's flat color
     pixel-for-pixel at the crop boundary. Fading all four edges to
     transparent (not just left/right) lets the true .project-detail-section
     background show through on every side instead of a visible rectangle. */
  -webkit-mask-image:
    linear-gradient(to right, transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
  -webkit-mask-composite: source-over, source-in;
  mask-image:
    linear-gradient(to right, transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
  mask-composite: add, intersect;
}

.detail-block {
  padding-bottom: clamp(56px, 9dvh, 120px);
  text-align: center;
}

/* The glass heading block hands straight off into .glass-reveal-pin, which
   supplies its own vertical rhythm — no extra bottom padding needed here. */
.detail-block-glass {
  padding-bottom: 0;
}

.detail-heading {
  font-family: var(--font-pretendard);
  font-weight: 800;
  font-size: clamp(30px, 4.4vw, 56px);
  line-height: 1.25;
  letter-spacing: -1px;
  color: var(--color-white);
  margin-bottom: 20px;
}

.detail-heading-gradient {
  background: linear-gradient(90deg, #5b8cff 0%, #b15cff 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.detail-subtext {
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: 16px;
  line-height: 1.7;
  color: rgba(255, 255, 255, 0.55);
  max-width: 800px;
  margin-inline: auto;
  margin-bottom: clamp(32px, 6dvh, 56px);
}

.detail-visual + .detail-visual {
  margin-top: 32px;
}

.detail-visual {
  display: flex;
  justify-content: center;
}

.detail-visual img,
.detail-visual video {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: min(680px, 62dvh);
  border-radius: 24px;
}

/* Figma "8" Layer — 4 phone mockups (혜택/결제/금융/카드). Back to a
   page-scroll-driven pin/sticky (like initSkillSequence's own pin) instead
   of a nested overflow:auto div — a plain in-flow 100vh section can't
   guarantee it's ever PRECISELY viewport-aligned before scroll-chaining
   kicks in, so the sequence could start mid-transition or the section
   could scroll past before it visually finishes. Sticky is the fix:
   .phone-sequence-pin supplies scroll runway (its extra height beyond the
   sticky's own IS the sequence's scroll distance), .phone-sequence-sticky
   locks in place — genuinely motionless, not just "looks pinned" — for
   that entire runway. top/height both account for the fixed GNB (which
   overlays rather than pushing content down) via --gnb-height, so the
   locked section is exactly "GNB bottom → screen bottom", never a sliver
   short. Progress = how far scroll has moved through the pin's runway
   (see getProgress() in script.js) — the exact same math
   initDeskSkillZoom/initSkillSequence already used elsewhere on this page. */
.phone-sequence-pin {
  position: relative;
  height: 280vh;
}

.phone-sequence-sticky {
  position: sticky;
  top: var(--gnb-height);
  height: calc(100dvh - var(--gnb-height));
  overflow: hidden;
}

/* No new visual design added here — .phone-showcase-bg exists so a
   background layer has a home that's structurally guaranteed to never
   move (it's a sibling of the panning content, not inside it), but today
   it's empty and simply lets the section's own ambient dark background
   show through unchanged. */
.phone-showcase-bg {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.phone-sequence-viewport {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

/* At +50% size the full 4-phone composite (~1564px tall) is taller than
   almost any sticky viewport, so it was never going to all fit on screen
   at once — instead of scrolling it, script.js PANS it (translateY on
   this element alone) so whichever phone is currently arriving stays
   roughly centered, while section/background/title stay completely still.
   position:absolute + top:50%/margin-left:auto-center replaces the old
   margin-based layout since JS now owns this element's vertical position
   every frame. */
.phone-scroll-content {
  position: absolute;
  left: 50%;
  top: 0;
  width: min(780px, 88vw);
  aspect-ratio: 1254 / 2514;
  transform: translateX(-50%);
}

/* Left column (01 혜택 / 02 금융) and right column (03 결제 / 04 카드,
   offset down to match Figma's staggered composition) — percentages are
   each phone's exact fraction of the 1254×2514 composite, so this stays
   correct at any stage size instead of needing fixed px per breakpoint. */
.phone-mockup-item {
  position: absolute;
  width: 42.8%;
  height: 41.63%;
  will-change: transform, opacity;
}

.phone-mockup-item img {
  display: block;
  width: 100%;
  height: 100%;
}

.phone-mockup-1 {
  left: 0;
  top: 0;
}

.phone-mockup-2 {
  left: 0;
  top: 49.19%;
}

.phone-mockup-3 {
  left: 57.16%;
  top: 9.15%;
}

.phone-mockup-4 {
  left: 57.16%;
  top: 58.34%;
}

/* Compact speech-bubble tooltip, anchored to each phone's own BOTTOM edge
   (lives inside the same .phone-mockup-item wrapper as the phone image, so
   it always tracks that phone's position/size automatically — no separate
   viewport-relative coordinates). Visibility is a class toggle from
   script.js at a scroll-progress threshold; the actual pop is a real,
   fixed-duration CSS transition (not scroll-coupled) so it reads as a
   fast, deliberate "pop" regardless of scroll speed, and still reverses
   cleanly since transitions run symmetrically on class-off too. */
.phone-tooltip {
  position: absolute;
  top: 100%;
  left: 50%;
  margin-top: 28px;
  transform: translate(-50%, 8px) scale(0.94);
  opacity: 0;
  background: #000000;
  color: #ffffff;
  font-family: var(--font-pretendard);
  font-size: 17px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  padding: 10px 20px;
  border-radius: 9999px;
  pointer-events: none;
  transition: opacity 180ms ease-out, transform 180ms ease-out;
}

.phone-tooltip.is-visible {
  opacity: 1;
  transform: translate(-50%, 0) scale(1);
}

/* Tail now sits above the bubble, pointing up at the phone. */
.phone-tooltip::after {
  content: '';
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  width: 11px;
  height: 11px;
  background: #000000;
  clip-path: polygon(50% 0, 0 100%, 100% 100%);
}

@media (prefers-reduced-motion: reduce) {
  .phone-sequence-pin {
    height: auto;
  }

  .phone-sequence-sticky {
    position: static;
    height: auto;
    overflow: visible;
  }

  .phone-sequence-viewport {
    overflow: visible;
  }

  .phone-scroll-content {
    position: relative;
    left: auto;
    transform: none;
    margin-block: clamp(24px, 4vw, 48px);
    margin-inline: auto;
  }

  .phone-mockup-item {
    opacity: 1 !important;
    transform: none !important;
  }

  .phone-tooltip {
    opacity: 1 !important;
    transform: translate(-50%, 0) !important;
    transition: none !important;
  }
}

/* Seasonal section: same fixed-height-card convention as every other
   .detail-visual image (max-height: min(680px, 62dvh), border-radius:
   24px), just split into 3 cards side by side instead of one flattened
   image, so each season can carry its own animated GIF overlay. Gap of
   60px matches the spacing baked into the original combined screenshot. */
.detail-visual-seasonal {
  gap: 60px;
  flex-wrap: wrap;
}

.seasonal-card {
  position: relative;
  display: flex;
}

.seasonal-gif {
  position: absolute;
  top: 0;
  left: 0;
  width: 130px !important;
  height: auto !important;
  max-height: none !important;
  border-radius: 0 !important;
  pointer-events: none;
}

/* Figma "7" Layer root (node 1:21757). One continuous canvas from the
   title through Contents-img:
     BASE COLOR (background-color below, #060915 — read directly off the
       Figma frame fill) → z-index n/a, it's just paint, always behind
       everything
     BACKGROUND IMAGES (.detail-solid-bg-1/2/3) → z-index 1
     Contents-img → z-index 2
     Title → z-index 3
   overflow:hidden clips the background images' deliberate off-edge bleed
   (Figma positions them with negative left/top too — this isn't a bug,
   frames clip by default in Figma and this reproduces that). Safe to set
   here specifically because nothing inside this wrapper is
   position:sticky (unlike .project-detail-section itself, which must
   stay overflow:visible — see the comment up there). */
.detail-solid-section {
  position: relative;
  background-color: #060915;
  overflow: hidden;
}

/* Title wrapper: alignment only, per the request — .detail-heading's own
   font-family/size/weight/line-height/letter-spacing are untouched. */
.detail-solid-title-wrap {
  position: relative;
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

/* Three real background-image assets exported straight from Figma (nodes
   1:21760, 1:21801, 1:21800 respectively) — not a CSS gradient
   approximation. Sized/positioned as percentages of Figma's 1920px design
   width (vw), matching the exact px/1920 ratio Figma itself uses, so they
   land in the same relative spot at any viewport width. Full-bleed
   background-image sizing per spec: cover + center + no-repeat. */
.detail-solid-bg {
  position: absolute;
  z-index: 1;
  pointer-events: none;
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

.detail-solid-bg-1 {
  left: 31.56vw;
  top: -8.02vw;
  width: 67.55vw;
  height: 67.55vw;
  background-image: url('assets/images/detail1-solid-bg-1.svg');
}

/* The large blurred/rotated glow (Figma: 47.68deg rotation + 13.5px blur
   + 10% opacity) — computed as a single element by folding Figma's
   center-aligned inner-in-outer wrapper into one box, since rotating
   around the element's own center (the default transform-origin) is
   equivalent to rotating the outer positioning wrapper Figma used. */
.detail-solid-bg-2 {
  left: -18.55vw;
  top: 0.2vw;
  width: 51.19vw;
  height: 51.19vw;
  opacity: 0.1;
  filter: blur(0.7vw);
  transform: rotate(47.68deg);
  background-image: url('assets/images/detail1-solid-bg-2.png');
}

.detail-solid-bg-3 {
  left: 70.42vw;
  top: 63.44vw;
  width: 44.95vw;
  height: 44.95vw;
  opacity: 0.14;
  background-image: url('assets/images/detail1-solid-bg-3.png');
}

.detail-solid-contents-block {
  position: relative;
  z-index: 2;
  padding-top: clamp(24px, 4.3vw, 82px);
  padding-bottom: clamp(56px, 9dvh, 120px);
}

/* The Contents-img frame — clipping lives HERE only, never on
   .detail-solid-section, so the rest of the layer/page is unaffected.
   Nothing currently overflows this box (the <img> has no height cap of
   its own, so it always renders its full, unclipped native height and
   this wrapper naturally grows to match) — this is a defensive boundary,
   not a workaround for a sizing bug: if anything inside ever did extend
   past the image's own real bottom edge, this guarantees it's cut there
   rather than bleeding into whatever comes after. */
.detail-solid-contents-img {
  position: relative;
  overflow: hidden;
}

.detail-block-last {
  padding-bottom: 0;
}

/* AS-IS → TO-BE scroll reveal (Liquid Glass UI). The stage sticks to
   top: 20px for the pin's entire scroll range — not centered in the
   viewport. Within that fixed-position stage, AS-IS renders once and
   never moves, while TO-BE starts translated fully below the stage and
   animates up to translateY(0) as reveal progress goes 0→1 (driven by
   script.js's initGlassReveal, unchanged), visually covering AS-IS.
   --tobe-hold-vh is extra pin height, beyond what the reveal itself
   needs, purely so the stage keeps sitting at top:20px (progress clamped
   at 1, i.e. TO-BE fully arrived) for that many extra vh of scroll before
   naturally releasing into normal document flow — no JS scroll-locking or
   timers involved, just a longer pin combined with clamped progress. */
.glass-reveal-pin {
  position: relative;
  --tobe-hold-vh: 70;
  height: calc(240vh + var(--tobe-hold-vh) * 1vh);
  /* margin (not padding) — margin doesn't count toward offsetHeight, so
     script.js's scrollDistance calculation is unaffected by this; it only
     adds visual space in the normal document flow before the next section
     starts. */
  margin-bottom: clamp(64px, 10dvh, 140px);
}

/* Fixed height matching .glass-reveal-stage exactly (top-aligned content,
   so unlike the old center-aligned version there's no need to float the
   sticky wrapper's height to the viewport size — it only ever needs to be
   tall enough to contain the stage without the overflow:hidden below
   clipping it). */
.glass-reveal-sticky {
  position: sticky;
  top: 20px;
  height: 1040px;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  overflow: hidden;
}

/* AS-IS/TO-BE is treated as one fixed-size design canvas, not a responsive
   layout container — it does not shrink to fit the viewport. This outer
   stage (1120×1040) is just a bounding box that both the AS-IS card
   (550×810) and the TO-BE container (880×880, defined below) center
   within — neither is a responsive wrapper. If the viewport is narrower
   than the stage, .glass-reveal-sticky's overflow:hidden clips it rather
   than anything inside scaling itself down. */
.glass-reveal-stage {
  position: relative;
  flex-shrink: 0;
  width: 1120px;
  height: 1040px;
}

.glass-asis,
.glass-tobe {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.glass-asis img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
}

/* TO-BE is one fixed design unit — background box + border + "TO-BE"
   label + the two mobile screens all move together as a single layer
   (the scroll-driven transform is applied to #glass-tobe, the ancestor of
   this whole container, never to the videos individually — see
   initGlassReveal in script.js, unchanged by this pass). Colors are
   sampled directly from the original Figma comparison capture
   (assets/images/detail1-glass-tobe.png): #223549 fill, #90c9ff border,
   #006eff pill. Fixed width/height, matching the "AS-IS is the smaller
   card, TO-BE is the much wider landscape card" size relationship from
   that design — this box does not stretch with the viewport. */
.glass-tobe-container {
  /* Taken out of .glass-tobe's flex layout entirely and positioned
     against the viewport instead: .glass-tobe (inset:0 within the
     1120px-wide, viewport-centered .glass-reveal-stage) has its own
     horizontal center already coinciding with the viewport's center — so
     left:50%/translateX(-50%) here centers on the true viewport, letting
     width exceed the 1120px stage without being clipped by it. */
  position: absolute;
  top: 80px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  box-sizing: border-box;
  width: calc(100vw - 200px);
  /* Height controls the bottom gap only: padding-top/margin-top above the
     video block are untouched, so raising height just gives
     margin-bottom:auto on .glass-tobe-videos more slack to absorb below
     the video — badge and video position stay exactly where they are,
     only the container's own bottom edge moves down. Currently ~20px of
     bottom gap (was 710.04px / ~10px before this pass). */
  height: 720.04px;
  padding: 90px 56px 5px;
  border: 5px solid #90c9ff;
  border-radius: 48px;
  background: #223549;
  box-shadow: 0 30px 70px rgba(0, 0, 0, 0.45);
}

.glass-tobe-label {
  position: absolute;
  top: 24px;
  left: 50%;
  transform: translateX(-50%);
  display: inline-block;
  padding: 11px 24px;
  border-radius: 9999px;
  background: #006eff;
  color: var(--color-white);
  font-family: var(--font-pretendard);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 0.3px;
  white-space: nowrap;
}

/* Explicit margin-top (not auto-centering) so the gap under the "TO-BE"
   label is a fixed, deliberately tight value instead of whatever the
   center-split of the leftover space happens to be; margin-bottom:auto
   sends the rest of the slack below the videos instead, which is also
   what keeps them clear of the container's own bottom border/corner. */
.glass-tobe-videos {
  display: flex;
  align-items: center;
  gap: 39px;
  margin-top: 14px;
  margin-bottom: auto;
}

/* iPhone mockup frame around each screen recording: border acts as the
   device bezel (content-box sizing, so it wraps around the video's own
   fixed dimensions rather than squeezing into them and skewing the
   aspect ratio), plus a Dynamic-Island pill overlaid at the top.
   The bezel is a graphite tone rather than near-black — against this
   section's very dark navy background (#0a0d18) a near-black bezel and a
   dark drop shadow both read as invisible (delta of a few RGB values),
   so the frame needs an actual lighter tone plus a light outer rim to
   read as a distinct object rather than disappearing into the backdrop. */
.glass-tobe-frame {
  position: relative;
  display: inline-flex;
  border-radius: 17.55px;
  border: 2.5px solid #3a3d44;
  background: #3a3d44;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14), 0 24px 60px rgba(0, 0, 0, 0.55),
    inset 0 0 0 1px rgba(255, 255, 255, 0.12);
  overflow: hidden;
}

.glass-tobe-frame::after {
  content: '';
  position: absolute;
  top: 16px;
  left: 50%;
  transform: translateX(-50%);
  width: 34%;
  height: 20px;
  background: #000;
  border-radius: 12px;
  z-index: 2;
  pointer-events: none;
}

/* Video display size and aspect-ratio are untouched by this pass — width
   is still derived per-video via aspect-ratio so neither one stretches,
   crops, or loses its true proportions. */
.glass-tobe-video {
  display: block;
  width: auto;
  height: 586.04px;
  object-fit: contain;
}

.glass-tobe-video-1 {
  aspect-ratio: 648 / 1418;
}

.glass-tobe-video-2 {
  aspect-ratio: 518 / 1126;
}

.glass-tobe {
  transform: translate3d(0, 100%, 0);
  will-change: transform;
}

@media (max-width: 900px) {
  .detail-hero {
    flex-direction: column;
    align-items: stretch;
    padding-bottom: 80px;
  }

  .detail-hero-text {
    max-width: none;
    padding-left: 0;
  }

  .detail-block {
    padding-bottom: 80px;
  }

  .detail-subtext {
    margin-bottom: 36px;
  }
}

/* "char" (Figma node 72:31103) — "Building My Digital Self". Background
   responsibility is split between two independent stacks now instead of
   one shared full-bleed black section: the hashtag stage
   (.char-keyword-pin/-sticky, #000000, a full 100vh/100dvh) and the
   logo/gallery stack below (.char-profile-content, #ffffff) — see each
   rule's own comment. .char-section itself draws nothing (no background,
   no layout) — it's just the two children's shared containing block. */
.char-section {
  width: 100%;
}

/* Real scroll runway for the hashtag stage's pin/sticky — same
   convention as every other pinned section on this page (a tall pin
   supplies real document scrollY, the sticky stays flush with the
   viewport top while it's pinned). The full motion timeline is now:
   scatter entrance (10 tags staggered in over ~2.99s + the last tag's own
   ~0.8s fade-in = ~3.79s), a 0.9s full-exposure hold, a 0.7s non-core
   fade-out, a 0.4s after-fade hold, a 1.5s gather, a 0.8s gathered hold, a
   0.8s core scatter-out (overlapping the last ~0.6s of it, the final
   message enters over 0.8s), a 1.0s message hold, then the single scroll
   nudge — see initCharKeywords()'s play() for the exact constants — roughly
   10.5s total. 500vh gives ~440vh of real pinned distance (500-60) at
   the same ~41vh/s runway pace used throughout this section's timing
   history — enough for the whole sequence to finish while still pinned
   at an ordinary scroll pace, without an excessively long empty-feeling
   scroll (spec: "지나치게 긴 빈 스크롤은 만들지 않는다"). No wheel/touch
   interception anywhere — this is a plain CSS pin, the user can always
   keep scrolling through it (the phase-10 scroll nudge is a single
   scrollBy(56px), not a pin-release mechanism). 540vh (was 500vh) — the
   stage itself grew from 60vh/60dvh to a full 100vh/100dvh tall sticky
   below, which alone would have eaten ~40vh straight out of the pinned
   scroll distance (effective pinned distance = pin height − sticky
   height); bumped by that same ~40vh so the actual runway budgeted for
   the ~10.5s sequence (~440vh effective either way) is unchanged. */
.char-keyword-pin {
  position: relative;
  width: 100%;
  height: 540vh;
  height: 540dvh;
}

/* The black hashtag stage itself — now a full 100% of the viewport
   height (was a fixed 60vh/60dvh band), so the black background fills
   the screen edge-to-edge with no white strip visible below it in the
   same view (spec: "검정 배경이 섹션의 상단부터 하단까지 빈틈없이 채워져야").
   Explicit `height` (a min-height alone was tried first and reverted —
   min-height doesn't establish a definite height for CHILDREN's percentage
   heights to resolve against, so .char-header/.char-keyword-stage's own
   `height:100%` silently collapsed to 0, and with it every tag's
   percentage-based top/left in SCATTER_POSITIONS; a plain `height`
   doesn't have that problem). dvh as the enhanced value (not svh) is
   deliberate: unlike svh, which is pinned to the SMALLEST the viewport
   ever gets, dvh continuously tracks whatever the browser's actual
   current visible height is, so the black fill never falls short of the
   real viewport even as mobile browser chrome shows/hides while
   scrolling through this pinned stage. Full-bleed width (100vw via the
   standard negative-margin breakout, since
   .char-section itself no longer spans edge-to-edge on its own). top:0
   (not var(--gnb-height)) is a deliberate fix: the sticky used to start
   BELOW the fixed GNB, so the strip from y:0 to y:var(--gnb-height) —
   exactly where the GNB floats — showed whatever sat behind it (the
   page's own white background, since neither .char-keyword-pin nor
   .char-section paint anything there) instead of this stage's own black.
   Starting the black box at the true viewport top instead closes that
   gap; the GNB (unchanged position/z-index) simply overlays on top of it,
   same as it does over every other section's own background.
   overflow:hidden is the hard guarantee that tags/overlay can never
   visually spill outside this box. */
.char-keyword-sticky {
  position: sticky;
  top: 0;
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  height: 100vh;
  height: 100dvh;
  background: #000000;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  box-sizing: border-box;
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  /* No pin/sticky at all — .char-header (via initCharKeywords()'s own
     reduced-motion path, applyFinalStateInstantly()) already renders the
     finished rallied state with no transition, so there's nothing to
     pin toward; the stage just sits in normal flow at its full 100dvh. */
  .char-keyword-pin {
    height: auto;
  }

  .char-keyword-sticky {
    position: relative;
    top: 0;
  }
}

.char-header {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  padding-inline: 10px;
  box-sizing: border-box;
  /* Anchors #char-keyword-overlay (position:absolute) to this box instead
     of the viewport — the overlay scrolls naturally with the section
     since nothing here is pinned/sticky (only .char-keyword-sticky, its
     ancestor, is). transform-origin:center centers the fit-scale shrink
     initCharKeywords() applies when the 10 tags would otherwise overflow
     the full-height stage (spec: "태그가 잘리면 내부 태그 그룹만 비율
     축소"), so it shrinks toward the middle of the stage, not a corner. */
  position: relative;
  transform-origin: center center;
}

/* Logos + gallery — a fully separate white stack below the black hashtag
   stage, with its own real top padding (spec: "상당한 세로 간격") so the
   two never read as one continuous section, plus a bottom padding that
   keeps the gallery clear of the footer. gap between .char-logos and
   .char-gallery is the SAME value .char-section's own flex gap used to
   provide (unchanged, per spec: "로고와 첫 이미지 사이의 기존 간격은
   유지"). */
.char-profile-content {
  width: 100%;
  background: #ffffff;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(40px, 4.17vw, 80px);
  padding-top: clamp(140px, 12vw, 240px);
  padding-bottom: clamp(64px, 8.33vw, 160px);
  padding-inline: 10px;
  box-sizing: border-box;
}

/* The scattered-cluster stage — position:relative anchor for all 10
   .char-keyword tags (each position:absolute, placed via inline
   left/top % written by initCharKeywords()'s measureScatterStage()) plus
   the reparented #char-keyword-overlay and #char-final-message, both
   siblings of this element inside .char-header. No flex/grid here at
   all — there is no "row" concept left; scatter coordinates alone decide
   where every tag sits. */
.char-keyword-stage {
  position: relative;
  width: 100%;
  height: 100%;
}

/* Pill look lives entirely on .char-keyword itself (not a stage-scoped
   descendant selector) specifically so a keyword keeps looking identical
   after initCharKeywords() reparents the 3 core tags into
   #char-keyword-overlay, which is a sibling of .char-keyword-stage, not a
   descendant of it. */
.char-keyword {
  position: absolute;
  /* left/top are written inline per-tag (percentage of the stage box,
     measured once at init/load/resize — never per animation frame) by
     measureScatterStage() in initCharKeywords(). translate(-50%,-50%)
     below is what makes that left/top mark the tag's CENTER, matching
     the SCATTER_POSITIONS coordinates. */
  margin: 0;
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: clamp(14px, 1.56vw, 30px);
  line-height: 1.08;
  color: #000000;
  text-align: center;
  /* Always exactly one line, regardless of how long a given phrase is —
     a long core phrase must never wrap to 2 lines. display:inline-flex +
     align-items:center vertically centers the text inside the pill. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  white-space: nowrap;
  width: fit-content;
  /* Height grows via padding-block only — font-size and padding-inline are
     both untouched, so the pill's width still tracks its own text exactly
     as before, only its vertical frame gets taller. */
  padding-block: clamp(13px, 1vw, 18px);
  padding-inline: clamp(14px, 1.15vw, 22px);
  border: none;
  border-radius: 999px;
  background: #ffffff;
  box-sizing: border-box;
  opacity: 0;
  filter: blur(8px);
  /* scale(0.8): the tag starts at 80% of its final rendered size and
     grows to 100% as it enters (spec: "최종 크기의 80%에서 100%까지 약 20%
     커지는 모션") — the -50%,-50% translate keeps this centered on the
     tag's own scatter point (not a corner), since transform-origin stays
     at its default center. Once a core tag reparents into
     #char-keyword-overlay, the more specific .char-keyword-overlay
     .char-keyword rule below overrides both the anchor (top:0/left:0)
     and transform-origin (0 0) its own FLIP translate+scale math needs
     instead — the two never apply at the same time on the same element,
     since a tag's entrance transition is always long finished before
     gather ever reparents it (see initCharKeywords()'s phase timeline). */
  /* --tag-rot/--tag-scale: set once per tag by applyScatterPositions()
     (never per frame) — a small fixed tilt and size variation so the
     cluster reads as organic/irregular rather than a tidy uniform grid of
     identical pills. Multiplied into the SAME scale the 80%->100% entrance
     grow already used, so a tag's own size variation and its entrance
     motion compose into one transform instead of fighting. */
  transform: translate(-50%, -50%) rotate(var(--tag-rot, 0deg)) scale(calc(0.8 * var(--tag-scale, 1)));
  /* 350ms — kept equal to initCharKeywords()'s own ENTER_DURATION_MS.
     Per-tag stagger itself comes from WHEN initCharKeywords() adds .is-in
     to each tag (its own fixed delay-array schedule), not from any
     per-element transition-delay here — every tag shares this one
     rule/duration. */
  transition: opacity 350ms cubic-bezier(0.22, 1, 0.36, 1),
    filter 350ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 350ms cubic-bezier(0.22, 1, 0.36, 1);
}

.char-keyword.is-in {
  opacity: 1;
  filter: blur(0);
  /* scale(1): settles at exactly its natural, unscaled size — no 0.8
     residue left over once the entrance transition ends, so anything
     measuring this tag's rect afterward (the gather FLIP math) reads its
     true at-rest size. The -50%,-50% anchor stays identical to the base
     rule's — only scale changes here, position never drifts. */
  transform: translate(-50%, -50%) rotate(var(--tag-rot, 0deg)) scale(var(--tag-scale, 1));
}

/* The 7 non-core tags' fade-out, once the full-exposure hold has ended —
   fully invisible (never a half-visible "dim" look), still at their
   original scatter position (no transform here at all, so is-in's own
   translate(-50%,-50%) scale(1) simply keeps applying — spec: "위치는
   scatter 위치 그대로 유지" / "scale: 1 유지"). Declared AFTER .is-in on purpose: both selectors share the same
   (0,0,2,0) specificity, and a tag carries BOTH classes at once during
   this phase, so source order alone is what lets is-out's opacity/filter
   win over is-in's without any !important. pointer-events:none keeps a
   faded-out tag from swallowing clicks/hovers meant for whatever's
   underneath. */
.char-keyword.is-out {
  opacity: 0;
  filter: blur(4px);
  pointer-events: none;
  transition: opacity 300ms ease, filter 300ms ease;
}

/* Final "active" emphasis on the 3 gathered keywords. The old white
   border-color/text-color bump made sense against the old transparent
   pill; now that every pill is a solid white fill with black text (spec:
   "active 상태에서 흰색 border가 다시 생기지 않게 한다"), only the weight bump
   still applies — there's no border to color and the text is already at
   full contrast. */
.char-keyword--active {
  font-weight: 500;
  transition: font-weight 400ms ease;
}

/* The 3 core keywords' destination layer — sits above the (now hidden)
   remaining tags via z-index, never shares a transform or layout slot
   with them (spec: separate overlay, original scatter layout never
   "waits" for them or re-includes them). */
.char-keyword-overlay {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}

/* Position/size are both written inline by JS (FLIP math) — this only
   fixes the anchor point (top-left) so scale() grows the box toward its
   target corner instead of around its center, which is what lets a plain
   translate+scale pair land the box exactly on target. Overrides the
   scattered base rule's translate(-50%,-50%)/center origin, since by the
   time a tag is here its entrance motion is long finished (see
   initCharKeywords()'s phase timeline). */
.char-keyword-overlay .char-keyword {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: 0 0;
  opacity: 1;
  filter: blur(0);
}

/* 400ms — kept equal to initCharKeywords()'s own GATHER_MS constant, so
   the char-keyword--active emphasis class (added via a timer fired at
   GATHER_MS) lands exactly when this transition visually finishes, not
   ~100ms before it. Deliberately the snappiest of the bunch (spec: this
   was the phase that read slowest, so it now needs to feel the quickest
   relative to its own travel distance) — same easing, same path/final
   alignment (computeGatherTargets() untouched), just faster. */
.char-keyword-overlay .char-keyword.is-gathering {
  transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* The gathered line's own quick exit once the mirage/sand dissolve takes
   over (see runDissolveEffect() in initCharKeywords()) — a short opacity-
   only crossfade so the real DOM pill fades out WHILE the canvas
   particles are simultaneously spreading from the exact same spot,
   instead of the pill vanishing a frame before any dust appears or
   lingering a frame after (spec: "원본과 입자 전환 타이밍을 자연스럽게 겹쳐").
   Never touches transform — the tag stays exactly where gather left it,
   dissolving in place; the particles alone carry the "flying apart"
   motion. */
.char-keyword-overlay .char-keyword.is-dissolving {
  transition: opacity 200ms ease;
  opacity: 0;
}

/* The closing line, centered on the black stage — two plain text rows,
   never a pill (spec: "배경 없음, border 없음, pill 형태 사용 금지"). No
   fade/blur/scale reveal anymore — the character-by-character typing
   itself (see the typing block in initCharKeywords()) IS the reveal, so
   this box just sits at its final position/size the whole time; it's
   invisible only because there's no text in it yet. `width` is set
   inline, once, by measureFinalMessageSize() — sized to the FINAL fully-
   typed text ahead of time so revealing characters never changes this
   box's own dimensions (spec: "타이핑 중 레이아웃이 좌우로 움직이지 않도록").
   Shown only once the core tags' sand-dissolve has fully finished — never
   overlapping it (spec: "입자 소멸이 끝나기 전에 마지막 문구가 나타나면 안 됨"). */
.char-final-message {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 6;
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.14em;
  color: #ffffff;
  text-align: center;
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: clamp(28px, 4vw, 56px);
  line-height: 1.15;
  pointer-events: none;
  transform: translate(-50%, -50%);
}

.char-final-line {
  position: relative;
  display: inline-block;
  white-space: nowrap;
  min-height: 1em;
}

/* Absolute so it can never affect the line's own content width — the
   line's box (and therefore the parent flex column's centering of it)
   only ever reflects the typed text itself (spec: "커서 때문에 텍스트 폭이나
   중앙 정렬이 흔들리지 않도록"). Moved between line 1 and line 2 by plain
   DOM appendChild (moving an already-attached node), never recreated. */
.char-final-cursor {
  position: absolute;
  top: 0.05em;
  left: 100%;
  margin-left: 0.05em;
  width: 2px;
  height: 0.95em;
  background: #ffffff;
  animation: charFinalCursorBlink 1s step-end infinite;
}

.char-final-cursor.is-fading {
  animation: none;
  opacity: 0;
  transition: opacity 300ms ease;
}

@keyframes charFinalCursorBlink {
  0%, 49% { opacity: 1; }
  50%, 100% { opacity: 0; }
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce) {
  .char-keyword,
  .char-keyword.is-out,
  .char-keyword-overlay .char-keyword.is-gathering,
  .char-keyword-overlay .char-keyword.is-dissolving,
  .char-final-cursor {
    transition: none;
    animation: none;
  }
}

/* Post-first-cycle scroll hint — shown once, in-memory only (see
   initCharKeywords()'s hasShownScrollHint in script.js), the instant the
   very first "마침내 / Product Builder" typing finishes; stays visible
   through every later replay of the main loop, since nothing in
   resetVisualState()/stopLoop() ever touches this element. A real
   <button>, not the plain aria-hidden <span> .hero-scroll-hint uses above,
   since this one is actually clickable/focusable. Lives inside
   .char-keyword-sticky (a sibling of .char-header, so it's never caught by
   fitStageScale()'s transform on .char-header) — position:sticky still
   scrolls it away with the rest of the section once the pin's own runway
   ends, with no extra show/hide logic needed for that. */
.char-scroll-hint {
  position: absolute;
  left: 50%;
  bottom: clamp(32px, 5vh, 48px);
  transform: translate3d(-50%, 0, 0);
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  background: transparent;
  color: #ffffff;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  z-index: 2;
  transition: opacity 400ms ease;
}

.char-scroll-hint svg {
  display: block;
  width: 20px;
  height: 20px;
}

.char-scroll-hint:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 3px;
}

.char-scroll-hint.is-visible {
  opacity: 1;
  pointer-events: auto;
  /* Delayed exactly by the fade-in's own duration so the up/down float
     only starts once the fade has visually settled, never overlapping it. */
  animation: charScrollHintFloat 1.8s ease-in-out 400ms infinite;
}

@keyframes charScrollHintFloat {
  0%, 100% { transform: translate3d(-50%, 0, 0); }
  50% { transform: translate3d(-50%, 7px, 0); }
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce) {
  .char-scroll-hint {
    transition: none;
  }

  .char-scroll-hint.is-visible {
    animation: none;
  }
}

/* The sand-dissolve particle overlay — one canvas, created fresh per
   dissolve and torn down immediately once it finishes (see
   runDissolveEffect()/cleanupDissolve() in initCharKeywords()), never left
   sitting in the DOM between cycles. position:fixed + viewport-sized so
   the canvas's own pixel coordinates line up 1:1 with each core tag's
   getBoundingClientRect() (viewport-relative), with no dependency on
   where .char-header/.char-keyword-stage happen to be scrolled to. */
.char-dissolve-canvas {
  position: fixed;
  inset: 0;
  z-index: 6;
  pointer-events: none;
}

/* logo1-9 — nine tool logos in fixed order, centered as one group under the
   Char title/tags. Desktop: one row; width-constrained viewports shrink the
   logo size first (clamp()) before ever wrapping. Each logo keeps its own
   original aspect ratio (object-fit: contain on the img, no forced square) —
   max-height on .char-logo-item is what makes differently-proportioned
   marks read as visually "the same size" without stretching any of them. */
.char-logos {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: clamp(11px, 1.75vw, 28px);
  width: 100%;
  /* Tool logos removed (spec: 로고 삭제, 영역의 높이/흰 여백은 유지) — this
     min-height reserves exactly the space .char-logo-item's own height used
     to occupy, so the gallery below never shifts up now that the item divs
     (and their height) are gone. Kept in sync with the two breakpoints
     below. */
  min-height: clamp(28px, 2.92vw, 45px);
}

.char-logo-item {
  flex-shrink: 0;
  height: clamp(28px, 2.92vw, 45px);
  display: flex;
  align-items: center;
  justify-content: center;
}

.char-logo-item img {
  display: block;
  width: auto;
  height: 100%;
  max-width: 100%;
  object-fit: contain;
}

/* Tablet: 5 + 4 across two rows — each row's own flex-basis keeps a 5-item
   row from being wider than a 4-item row's centered group would allow. */
@media (max-width: 900px) {
  .char-logos {
    max-width: 300px;
    margin: 0 auto;
  }
}

/* Mobile: 3 x 3 grid — guarantees exactly three per row regardless of each
   logo's own natural width (flex-wrap alone can't guarantee a fixed count
   per row once marks have different widths). */
@media (max-width: 600px) {
  .char-logos {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    justify-items: center;
    max-width: 320px;
    min-height: clamp(25px, 7.7vw, 34px);
  }

  .char-logo-item {
    height: clamp(25px, 7.7vw, 34px);
  }
}

.char-gallery {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(6px, 0.52vw, 10px);
  width: 100%;
}

.char-gallery-row {
  display: flex;
  width: 100%;
  max-width: min(1042px, 92vw);
  gap: clamp(6px, 0.52vw, 10px);
}

/* The compare row's pair shares one Figma height (642px) at different
   natural widths — flex-grow set inline per item to each image's own
   Figma width keeps that exact ratio (and therefore the shared height)
   at any viewport size, since both images' own aspect-ratio is preserved
   via object-fit:cover. */
/* flex-basis: 0 is the fix — without it, flex-grow's split isn't purely
   proportional to the inline flex-grow numbers (it also weighs each
   item's own content-driven auto basis, which itself depends on the img's
   aspect-ratio), so the two images in a row silently ended up at
   DIFFERENT heights despite sharing a row (measured live: img1/img2 at
   676px/612px) even though Figma's own layout has both members of the
   pair at the exact same height (642px) and only the WIDTH differs, via
   the inline flex-grow values already matching Figma's own width numbers
   (519/513). This restores that — real bug fix, not a new design
   decision. */
.char-gallery-item {
  min-width: 0;
  flex-basis: 0;
}

.char-gallery-item img {
  display: block;
  width: 100%;
  height: auto;
  object-fit: cover;
}

.char-gallery-row-compare .char-gallery-item:first-child img {
  aspect-ratio: 519 / 642;
  /* object-fit:cover (inherited) already crops to fill the frame — this
     just moves WHERE that crop lands: anchored to the bottom so any
     trimming happens off the top of the photo, keeping the bottom
     (unchanged frame size, no added scale needed since cover already
     fills it). */
  object-position: center bottom;
}

.char-gallery-row-compare .char-gallery-item:last-child img {
  aspect-ratio: 513 / 642;
}

/* Same content-area width as .char-gallery-row above (min(1042px, 92vw))
   so the video's own left/right edges land exactly on the compare row's
   outer edges instead of its own, slightly narrower, 1024px reference —
   aspect-ratio (unchanged) still derives the video's height from that
   width, so nothing stretches. */
.char-gallery-video {
  width: 100%;
  max-width: min(1042px, 92vw);
  aspect-ratio: 1024 / 576;
  overflow: hidden;
}

.char-gallery-video video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Feature Section — mechanism referenced from vsion.global's main-page
   feature-section (live-inspected, not guessed): a single scroll-linked
   progress value (0–1) across the pin's whole runway drives a pure
   opacity crossfade between stacked feature items AND stacked feature
   images (their reference uses no translate/scale/clip on either — just
   opacity + visibility, confirmed by sampling computed styles at 21
   scroll positions). 500vh runway matches this codebase's own existing
   convention for a 5-item pinned sequence (see .desk-skill-pin above,
   also 5 items / 500vh) rather than copying vsion's specific ~9.6×
   viewport-height ratio. */
/* ---------- Role Wall (역할의 [벽] 경계 없이 -> fullscreen black reveal) ----------
   Scroll-scrub, driven by initRoleWallSection() in script.js: a single
   progress value (0-1), computed straight off (scrollY - pinTop) /
   (pin's own scroll distance) — no reserved/subtracted hold distance —
   drives every band directly: 0-0.15 read the initial pill, 0.15-0.65
   grow the center block from a narrow pill to a full 100%-of-sticky
   rectangle (measured off the sticky's own live clientWidth/clientHeight,
   never a guessed px/vw number) while fading the two side words out,
   0.45-0.8 fades the internal white message in, 0.8-1 holds the finished
   fullscreen black frame — nothing left to release until progress
   actually reaches 1, at which point the pin's own natural sticky-release
   (not a transform on Principles, which stays untouched) carries the user
   into it. top:0/height:100vh (not the site's usual --gnb-height offset)
   is deliberate here — the GNB's own existing auto-hide-on-scroll
   behavior (unchanged, see the top of this file) already gets it out of
   the way while the user is actively scrolling through this section, so
   the black frame can genuinely reach the true top of the viewport. */
/* overflow-x:hidden alone (overflow-y left unset) is what was silently
   breaking position:sticky below: per the CSS Overflow spec, when only
   one axis is set to non-visible, the OTHER axis computes to `auto`, not
   `visible` — confirmed via computed style (`overflow: hidden auto`).
   That auto disqualifies this section as a valid non-scrolling ancestor
   for its sticky descendant, so .role-wall-sticky stuck relative to this
   section's own box instead of the real viewport, i.e. didn't stick at
   all. overflow-y explicitly set to visible (not left to infer) keeps
   the horizontal clip (still needed once the block reaches 100vw) without
   ever creating that scroll-container candidacy. */
.role-wall-section {
  position: relative;
  width: 100%;
  background: #ffffff;
}

.role-wall-pin {
  position: relative;
  width: 100%;
  height: 400vh;
}

.role-wall-sticky {
  position: sticky;
  top: 0;
  height: 100svh;
  min-height: 100vh;
  width: 100%;
  overflow: hidden;
  background: #ffffff;
  /* Read by JS as the fixed visual gap kept between the side words and the
     black block's current (animated) edge — one source of truth so the
     rest-state CSS below and the per-frame JS position math can never
     drift apart. */
  --role-wall-gap: clamp(24px, 3vw, 64px);
}

.role-wall-side {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  margin: 0;
  font-weight: 700;
  font-size: clamp(20px, 3.4vw, 48px);
  color: #12151c;
  white-space: nowrap;
  z-index: 2;
  will-change: opacity, filter, right, left;
}

.role-wall-side--left {
  right: calc(50% + clamp(44px, 5vw, 84px) / 2 + var(--role-wall-gap));
}

.role-wall-side--right {
  left: calc(50% + clamp(44px, 5vw, 84px) / 2 + var(--role-wall-gap));
}

/* Rest state (progress 0) — also what initRoleWallSection()'s measure()
   reads as the animation's own "from" width/height/radius, so this and
   the JS can never drift out of sync, and it's automatically correct at
   any viewport width without a parallel guessed value in the JS. */
.role-wall-block {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: clamp(44px, 5vw, 84px);
  height: 70vh;
  border-radius: 999px;
  background: #000000;
  overflow: hidden;
  z-index: 1;
  will-change: width, height, border-radius;
}

.role-wall-message {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(4px, 0.8vw, 12px);
  padding: 0 24px;
  text-align: center;
  opacity: 0;
  filter: blur(12px);
  transform: translateY(20px) scale(0.96);
  will-change: opacity, filter, transform;
}

.role-wall-line {
  margin: 0;
  font-weight: 700;
  font-size: clamp(22px, 4vw, 56px);
  line-height: 1.3;
  color: #ffffff;
  white-space: nowrap;
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  .role-wall-pin {
    height: auto;
  }

  .role-wall-sticky {
    position: relative;
    height: calc(100dvh - var(--gnb-height));
  }

  .role-wall-side {
    display: none;
  }

  .role-wall-block {
    width: 100%;
    height: 100%;
    border-radius: 0;
  }

  .role-wall-message {
    opacity: 1;
    filter: none;
    transform: none;
  }
}

@media (max-width: 480px) {
  /* Narrowest phones only: shrinks the initial pill/gap floor further so
     the two side words + block never approach the viewport edge before
     any scroll happens (spec: 작은 해상도에서 잘림/가로 스크롤 없음). */
  .role-wall-side--left {
    right: calc(50% + 20px + 10px);
  }

  .role-wall-side--right {
    left: calc(50% + 20px + 10px);
  }

  .role-wall-block {
    width: 40px;
  }
}

.feature-section {
  position: relative;
  background: #000000;
  /* GNB "Principles" link (#principles) lands here — same offset the GNB
     uses everywhere else, so its own top content never sits under the
     fixed bar. */
  scroll-margin-top: var(--gnb-height);
}

/* 500vh base runway + 30vh extra tail — Feature 05 already holds
   indefinitely once its band is reached (see opacityFor() in script.js),
   so this only extends how long it stays fully visible before the pin
   releases into History, giving a bit more breathing room before History
   enters. Nothing about the crossfade timing/bands themselves changes. */
.feature-pin {
  position: relative;
  height: 530vh;
}

.feature-sticky {
  position: sticky;
  top: var(--gnb-height);
  height: calc(100dvh - var(--gnb-height));
  overflow: hidden;
  display: flex;
  align-items: center;
  gap: clamp(40px, 6vw, 100px);
  padding-inline: clamp(24px, 6vw, 120px);
}

.feature-left {
  position: relative;
  flex: 1.2 1 0;
  height: min(80%, 620px);
  display: flex;
  align-items: center;
  justify-content: center;
}

.feature-images {
  position: relative;
  width: 100%;
  height: 100%;
}

.feature-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  will-change: opacity;
}

.feature-right {
  position: relative;
  flex: 1 1 0;
  height: min(60%, 420px);
}

.feature-item {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(20px, 2vw, 32px);
  will-change: opacity;
}

.feature-keyword {
  font-family: var(--font-pretendard);
  font-weight: 600;
  font-size: clamp(13px, 1.1vw, 16px);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.6);
}

.feature-title {
  font-family: var(--font-pretendard);
  font-weight: 600;
  font-size: clamp(28.5px, 3.8vw, 55.1px);
  line-height: 1.2;
  letter-spacing: -0.02em;
  color: #ffffff;
  margin: 0;
}

/* Reuses this portfolio's own existing accent (--color-indigo-600),
   deliberately NOT vsion's own brand purple. */
.feature-title-accent {
  color: var(--color-indigo-600);
}

.feature-desc {
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: clamp(14px, 1.1vw, 16px);
  line-height: 1.6;
  color: rgba(255, 255, 255, 0.7);
  /* 34ch(=321px)에서는 쉼표/마침표로 끊어둔 줄이 다시 한 번 접혀서
     '생각합니다.' 같은 조각이 홀로 떨어졌다. 가장 긴 줄(427px)이 그대로
     한 줄에 들어가는 폭으로 넓힌다. 부모(.feature-right, 537px) 안에 든다. */
  max-width: 48ch;
  margin: 0;
}

.feature-pagination {
  position: absolute;
  left: clamp(24px, 3vw, 40px);
  bottom: clamp(24px, 3vw, 40px);
  display: flex;
  gap: 8px;
  list-style: none;
}

.feature-pagination li {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-pretendard);
  font-size: 12px;
  color: #ffffff;
  transition: border-color 300ms ease;
}

.feature-pagination li.active {
  border-color: #ffffff;
}

.feature-number {
  position: absolute;
  right: clamp(24px, 3vw, 40px);
  bottom: 0;
  font-family: var(--font-pretendard);
  font-weight: 700;
  font-size: clamp(80px, 10vw, 181px);
  line-height: 1;
  color: rgba(255, 255, 255, 0.05);
  pointer-events: none;
  user-select: none;
}

@media (max-width: 900px) {
  .feature-sticky {
    flex-direction: column;
    justify-content: center;
    gap: clamp(24px, 4vh, 40px);
    padding-block: clamp(80px, 12dvh, 140px);
  }

  .feature-left {
    height: 40%;
  }

  .feature-right {
    height: 45%;
  }

  .feature-title {
    font-size: clamp(26px, 7vw, 40px);
  }
}

/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce) {
  .feature-pin {
    height: auto;
  }

  .feature-sticky {
    position: static;
    height: auto;
    flex-direction: column;
    overflow: visible;
    padding-block: clamp(40px, 8vh, 96px);
  }

  .feature-item {
    position: relative;
    opacity: 1 !important;
  }

  .feature-item:not(:first-child) {
    margin-top: clamp(48px, 8vh, 96px);
  }

  /* Every image shown stacked (not just the active one) so nothing is
     hidden when motion is off — pairing with its own text item isn't
     pixel-adjacent in this fallback layout, but all content stays
     reachable, which is what matters here. */
  .feature-images {
    height: auto;
    display: flex;
    flex-direction: column;
    gap: clamp(24px, 4vh, 48px);
  }

  .feature-img {
    position: relative;
    opacity: 1 !important;
    aspect-ratio: 700 / 600;
  }

  .feature-pagination,
  .feature-number {
    display: none;
  }
}

/* Footer — one merged scene: the globe canvas is .site-footer's own
   full-bleed background (z-index:0, positioned/overflow-clipped by the
   footer itself), with the link list and giant typo as ordinary in-flow
   content stacked above it (z-index:1). No visible seam between a
   "globe section" and a "white footer" anymore — see .site-footer below
   for the single gradient that now spans the whole thing. */
.globe-stage {
  position: absolute;
  z-index: 0;
  left: 50%;
  top: 100%;
  /* Deliberately larger than the footer's own box on every side (vh-
     dominant so it stays big even on a narrow/short viewport) — the
     circle starts above the footer's top edge and extends well past its
     bottom, clipped only by .site-footer's own overflow:hidden, never by
     a visibly smaller decorative box of its own. aspect-ratio:1/1 keeps
     the sphere's projection math (radius derived from a single square
     cssSize in initFooterGlobe()) undistorted regardless of this box's
     placement. */
  width: calc(100% - 200px);
  aspect-ratio: 1 / 1;
  transform: translate(-50%, -50%);
}

.globe-canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.globe-glow {
  position: absolute;
  left: 50%;
  top: 24%;
  width: 52%;
  height: 18%;
  transform: translateX(-50%);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.46);
  filter: blur(70px);
  pointer-events: none;
}

@media (max-width: 767px) {
  /* Spec: "지구본은 중앙 기준으로 더 크게 크롭해도 되지만 비율은 유지" — wider
     than 100vw on purpose so it reads as a big, cropped-at-the-edges
     backdrop rather than shrinking to fit a narrow screen; still a true
     square, still clipped only by .site-footer's overflow:hidden. */
  .globe-stage {
    width: calc(100% - 48px);
  }
}

/* Plain scroll-distance spacer right before the footer — real block
   height (never a margin-top baked into .site-footer itself), pure white,
   no content/decoration of its own, so the footer's gradient scene never
   arrives right on the heels of the previous section. */
.footer-entry-gap {
  width: 100%;
  height: clamp(180px, 30vh, 360px);
  background: #ffffff;
}

@media (max-width: 767px) {
  .footer-entry-gap {
    height: clamp(120px, 22vh, 220px);
  }
}

/* Site footer — flat, borderless, pure white/black, full viewport width.
   Two stacked pieces: a right-aligned small project-link grid, then one
   full-width giant line ("Portfolio SEUL") hugging the very bottom. No
   card/border/shadow/radius anywhere (spec: "카드, 테두리, 그림자, 라운딩
   처리 금지"). */
/* position:relative + overflow:hidden is what keeps .globe-stage's
   deliberately-oversized square clipped to exactly this box — nothing
   spills past the footer's own edges. The same 3-stop gradient the old
   standalone .globe-section used, now spanning this whole (>=100dvh)
   box top-to-bottom as one continuous scene — no seam where a "white
   footer" used to begin. flex column + space-between is what pins the
   link row near the top and the giant typo to the very bottom with
   everything in between left open for the globe to show through, without
   either piece needing its own manual offset. */
.site-footer {
  position: relative;
  overflow: hidden;
  width: 100%;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  /* Same 3 colors as the old .globe-section, reached much sooner (10%/38%
     instead of 42%/100%) — over a box this much taller, holding off on
     the darker tone until 42% would leave the whole top third pale white,
     right where the link list sits (spec: readable "흰색 또는 밝은 회색"
     text needs real contrast under it independent of exactly where the
     circular globe's own edge happens to fall). */
  background: linear-gradient(180deg, #ffffff 0%, #eaf2ff 10%, #7894ba 38%);
  color: #111111;
  padding: clamp(32px, 4.5vw, 64px) clamp(20px, 4vw, 64px);
  box-sizing: border-box;
}

/* The closing line — one full-width, unwrapped, bold display line. Width
   is governed entirely by clamp() (never raw vw), verified in-browser to
   neither overflow nor visibly under-fill at common desktop/mobile
   widths. letter-spacing explicitly 0 per spec. */
/* Same box/position this used to be a text line in (spec: "레이아웃 크기와
   위치를 그대로 재사용") — only the text-specific properties are gone; the
   image itself carries its own sizing rule below. */
.site-footer-giant {
  position: relative;
  z-index: 1;
  margin: 0;
}

/* assets/images/portfolio-seul.png has zero transparent padding (its own
   bounding box is the full canvas, verified directly against the file),
   so width:100% here reproduces the same width/left-start/bottom
   position the old text line held — no extra crop/offset math needed.
   height:auto + object-fit:contain keep the file's real aspect ratio
   (3230x422) intact; max-width:100% is the belt-and-suspenders guard
   against ever exceeding the footer's own inline padding. */
.site-footer-giant img {
  display: block;
  width: 100%;
  max-width: 100%;
  height: auto;
  object-fit: contain;
}

/* Floating email-contact toast — sits in normal flow directly above the
   giant "Portfolio SEUL" wordmark (right-aligned via align-self, so it
   never overlaps the image below it). Three-role split so no two motions
   ever fight over the same element's transform/opacity:
     .footer-contact-reveal (outer) — owns layout position AND the ONE-SHOT
       reveal (opacity/visibility/transform). Once .is-revealed is added
       (initFooterContactReveal(), script.js) it is NEVER removed again —
       there is no second class to swap in later that could drop the
       visible state, which is exactly what caused the old "flashes then
       vanishes" bug (see the file's own history: that version toggled
       .is-entering -> .is-floating on this same element, and
       .is-floating never restated opacity:1/visibility:visible itself,
       so removing .is-entering silently reverted straight back to the
       hidden base rule).
     .footer-contact-float (middle) — owns ONLY the continuous idle float
       (transform, translate3d) — never touches opacity/visibility at all,
       so toggling it can never re-hide anything.
     .footer-contact-toast (inner <a>) — hover/focus/active. */
.footer-contact-reveal {
  position: relative;
  z-index: 1;
  align-self: flex-end;
  width: fit-content;
  max-width: min(460px, calc(100% - 8px));
  margin-bottom: clamp(16px, 3vw, 32px);
  /* Hidden-until-Footer-entry base state (spec: "새 메시지가 도착한 것처럼"),
     not display:none — the layout space stays reserved (align-self/margin
     above are unaffected by opacity/transform/visibility) so Footer never
     shifts when this pops in. */
  opacity: 0;
  visibility: hidden;
  transform: translate3d(0, 18px, 0) scale(0.95);
  pointer-events: none;
  transition: opacity 300ms ease; /* only meaningfully used by the prefers-reduced-motion path below */
}

/* The one and only state change this element ever makes, for the rest of
   this page load. Explicit opacity/visibility/transform/pointer-events
   here (not just the animation's own forwards-fill) is the actual fix —
   even if the animation were somehow interrupted or this rule's animation
   value ever changed later, the settled visible state is still declared
   directly, never solely implied by an animation nobody is removing. */
.footer-contact-reveal.is-revealed {
  animation: footerContactPopIn 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
  opacity: 1;
  visibility: visible;
  transform: none;
  pointer-events: auto;
}

@keyframes footerContactPopIn {
  0% { opacity: 0; transform: translate3d(0, 18px, 0) scale(0.95); }
  100% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}

.footer-contact-float {
  width: fit-content;
}

/* Applied once .footer-contact-reveal's own pop-in animationend fires
   (script.js) — the idle float only ever starts after the pop-in has
   genuinely finished, never overlapping it. This class only ever animates
   transform on THIS element; it can't affect .footer-contact-reveal's
   opacity/visibility above even if toggled repeatedly. */
.footer-contact-float.is-floating {
  animation: footerContactFloat 3.6s ease-in-out infinite;
}

/* :has() pauses the idle float the instant the inner link is hovered/
   focused, so the hover lift (below) is the only transform in play —
   without this the two would stack and the box would jump on entry/exit.
   Pausing (not removing) the animation never touches opacity, so the box
   staying visible through hover is unaffected either way. */
.footer-contact-float:has(.footer-contact-toast:hover),
.footer-contact-float:has(.footer-contact-toast:focus-visible) {
  animation-play-state: paused;
}

@keyframes footerContactFloat {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50% { transform: translate3d(0, -8px, 0); }
}

.footer-contact-toast {
  position: relative; /* anchors .footer-contact-dot to this box's own corner */
  display: inline-flex;
  align-items: center;
  gap: 20px; /* avatar<->text gap, trimmed a bit further from 26px (spec: "가로 간격을 현재보다 조금 축소") */
  background: #ffffff;
  border-radius: 20px;
  /* Left padding only, cut 30% (38px * 0.7) so the icon/text group shifts
     left inside the same unchanged box width/min-height — top/right/bottom
     stay the 2nd-pass value. */
  padding: 27px 38px 27px 26.6px;
  box-shadow: 0 6px 20px rgba(17, 17, 17, 0.12);
  text-decoration: none;
  cursor: pointer;
  transform: translate3d(0, 0, 0);
  transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow 220ms cubic-bezier(0.16, 1, 0.3, 1);
}

.footer-contact-toast:hover,
.footer-contact-toast:focus-visible {
  transform: translate3d(0, -4px, 0);
  box-shadow: 0 14px 30px rgba(17, 17, 17, 0.2);
}

.footer-contact-toast:focus-visible {
  outline: 2px solid #111111;
  outline-offset: 3px;
}

.footer-contact-text {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.footer-contact-en {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 20.8px; /* 16px * 1.3 */
  line-height: 1.3;
  color: var(--color-text-900);
  white-space: nowrap;
  transition: color 220ms ease;
}

.footer-contact-toast:hover .footer-contact-en,
.footer-contact-toast:focus-visible .footer-contact-en {
  color: #000000;
}

.footer-contact-kr {
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: 15.84px; /* 14.4px * 1.1 (10% enlarge) */
  line-height: 1.4;
  color: var(--color-text-600);
}

/* Circular character avatar — replaces the old mail-glyph icon in the same
   left-hand slot (spec: "원형 이미지 | 텍스트"). object-fit:cover + a square
   box keeps the source's own aspect ratio intact under the circular crop
   (never stretched into an oval); the source PNG's four corners (outside
   its own inscribed circle) carry a baked-in checkerboard placeholder
   texture, not real alpha — border-radius:50% here crops exactly those
   corners away, so only the circular face ever paints regardless. */
.footer-contact-avatar {
  width: 61.6px; /* 88px * 0.7 (30% reduction) */
  height: 61.6px;
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
  display: block;
  border: 1px solid rgba(17, 17, 17, 0.08);
  transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}

.footer-contact-toast:hover .footer-contact-avatar,
.footer-contact-toast:focus-visible .footer-contact-avatar {
  transform: translate3d(3px, -3px, 0);
}

/* New-message notification dot — purely decorative (aria-hidden, no text),
   position:absolute inside the toast's own top-right padding so it never
   affects the box's size or its flex-row alignment, and never crosses the
   box's own edge. pointer-events:none keeps the whole box clickable
   underneath it. */
.footer-contact-dot {
  position: absolute;
  top: 22px;
  right: 22px;
  width: 10.8px; /* 12px * 0.9 */
  height: 10.8px;
  border-radius: 50%;
  background: #ff3b30;
  pointer-events: none;
  opacity: 0;
  transform: scale(0.6);
  transition: opacity 250ms ease;
}

/* Pops in ~120-180ms after the box itself (initFooterContactReveal() in
   script.js schedules this), never before — the box's own .is-entering
   animation is already running by the time this class is added. */
.footer-contact-dot.is-entering {
  animation: footerContactDotPopIn 260ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

.footer-contact-dot.is-pulsing {
  animation: footerContactDotPulse 2.2s ease-in-out infinite;
}

@keyframes footerContactDotPopIn {
  0% { opacity: 0; transform: scale(0.6); }
  100% { opacity: 1; transform: scale(1); }
}

@keyframes footerContactDotPulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.25); opacity: 0.85; }
}

@media (max-width: 767px) {
  .footer-contact-reveal {
    align-self: center;
    max-width: calc(100% - 40px);
  }

  .footer-contact-toast {
    padding: 23px 30px 23px 21px; /* 18px 23px * 1.3 (2nd +30% pass); left cut a further 30% (30px * 0.7) */
    gap: 14px; /* trimmed a bit further from 18px, matching the desktop gap reduction */
  }

  .footer-contact-avatar {
    width: 47.6px; /* 68px * 0.7 (30% reduction) */
    height: 47.6px;
  }

  .footer-contact-en {
    white-space: normal;
  }

  .footer-contact-dot {
    width: 9px; /* 10px * 0.9 */
    height: 9px;
    top: 16px;
    right: 16px;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* No translate/scale/bounce, no floating loop, no pulse — just the
     plain opacity transition already declared on the base rules above,
     driven by JS adding .is-revealed directly on this path (no keyframe
     animation to wait on). */
  .footer-contact-reveal.is-revealed {
    animation: none;
    opacity: 1;
    visibility: visible;
    transform: none;
  }

  .footer-contact-float.is-floating {
    animation: none;
  }

  .footer-contact-dot.is-entering,
  .footer-contact-dot.is-pulsing {
    animation: none;
    opacity: 1;
    transform: scale(1);
  }
}

/* Scroll reveal */

.reveal {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 700ms cubic-bezier(0.16, 1, 0.3, 1), transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}

.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Respect reduced-motion preference */

@media (prefers-reduced-motion: reduce) {
  .hero-content > * {
    animation: none;
    opacity: 1;
  }

  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }

  * {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }

  html {
    scroll-behavior: auto;
  }
}

/* Skill Highlights — Figma "skill2" (node 415:64190), inserted between
   Skill and Role Wall. Scoped entirely to .skill-highlight-* classes so it
   never touches any shared/global selector; full-width per Figma (the
   section itself carries no max-width/centering wrapper — only its own
   .skill-highlights-track gets the left/right breathing room, matching
   Figma's own px-152 on the row rather than a page-wide container). Sizing
   is Figma's real 1920px-canvas px values expressed as clamp(min, vw, px)
   so the ratio is exact at ≥1920px and scales down smoothly below it,
   never the site's shared responsive breakpoints (this section has its own
   continuous scale instead of fixed steps). */
.skill-highlights-section {
  position: relative;
  width: 100%;
  background: #ffffff;
}

.skill-highlights-pin {
  position: relative;
  width: 100%;
  /* JS sets the real height once it measures the track's actual overflow
     width vs the viewport (see measureAndSetPinHeight() in script.js) —
     this is just a sane pre-JS/no-JS fallback so there's never a 0-height
     flash. */
  height: 260vh;
}

.skill-highlights-sticky {
  position: sticky;
  top: 0;
  height: 100svh;
  min-height: 100vh;
  width: 100%;
  overflow: hidden;
  display: flex;
  align-items: center;
  background: #ffffff;
  /* Same vertical breathing room the section itself used to carry before
     the pin/sticky wrapper existed — relocated here since this is now the
     element that actually fills the viewport. */
  padding-block: clamp(56px, 7.29vw, 140px);
  box-sizing: border-box;
}

/* Scroll-scrub track — JS writes translate3d(x, 0, 0) here every frame
   (see render() in initSkillHighlightsScrub()). No overflow here on
   purpose: clipping happens at exactly one place, .skill-highlights-sticky
   (the real viewport) — track itself must never clip its own children.
   flex-shrink: 0 is the actual fix for "slide stops around card 2": this
   track is a flex ITEM of .skill-highlights-sticky (display:flex), and
   without this, the outer flex algorithm was free to shrink track's own
   box down to sticky's available width whenever the 5 real cards' combined
   width exceeded it (the overwhelmingly common case) — track's own box
   then narrower than its (flex-shrink:0, non-shrinking) card children,
   which then overflowed THAT box and got clipped by the overflow:hidden
   that used to live here. will-change is NOT static here on purpose —
   see .is-in-view below: it's only ever applied while the section is
   near the viewport (spec: "will-change는... 화면 근처에 있을 때만"). */
.skill-highlights-track {
  display: flex;
  align-items: flex-start;
  gap: clamp(20px, 3.02vw, 58px);
  padding-inline: clamp(20px, 7.92vw, 152px);
  flex-shrink: 0;
}

/* ×0.7 of the previous clamp() min/preferred/max — card width only, so
   media/title/desc (all sized off this same width) shrink together;
   vertical gap is left at its previous value on purpose (spec: "간격은
   우선 현재 값을 유지"). No transition of its own — its transform/opacity
   are written every frame straight from JS (trailing lag + center-distance
   emphasis, see render() in initSkillHighlightsScrub()); a CSS transition
   on the same property would fight that per-frame write every tick. */
.skill-highlight-card {
  flex: 0 0 auto;
  width: clamp(168px, 37.226vw, 714.7px);
  display: flex;
  flex-direction: column;
  gap: clamp(16px, 1.93vw, 37px);
}

/* will-change scoped to exactly while initSkillHighlightsScrub() is
   actively rendering this section (an IntersectionObserver with a 50%
   rootMargin toggles this — see isNearViewport in the JS) — applied to
   track only while pinned/near-view, removed once scrolled far away so
   the browser doesn't keep a promoted compositor layer around for a
   section nobody's looking at. */
.skill-highlights-track.is-in-view,
.skill-highlight-card.is-in-view {
  will-change: transform;
}

/* Every one of the 5 cards shares this exact same frame — same width
   (from .skill-highlight-card above), same aspect-ratio, same background,
   no per-card variant. object-fit differs only by tag: the one video
   (card 1) keeps its original cover/crop treatment untouched; every img
   (cards 2-5) uses contain so the source is never cropped or distorted. */
.skill-highlight-media {
  position: relative;
  width: 100%;
  aspect-ratio: 1021 / 638;
  overflow: hidden;
  background: #f4f4f4;
}

.skill-highlight-media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
  display: block;
}

.skill-highlight-media img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: center;
  display: block;
}

/* ×0.8 of the previous clamp() min/preferred/max on both (this is now the
   SECOND ×0.8 pass on these two — the values above the previous pass
   left are the base this one is computed from), font-size only,
   weight/color/alignment/copy/line-breaks untouched. */
.skill-highlight-title {
  margin: 0;
  font-family: var(--font-pretendard);
  font-weight: 600;
  font-size: clamp(12.8px, 1.4976vw, 28.8px);
  line-height: 1.4;
  color: #000000;
}

.skill-highlight-desc {
  margin: 0;
  font-family: var(--font-pretendard);
  font-weight: 400;
  font-size: clamp(9.6px, 1.1648vw, 22.4px);
  line-height: 1.4;
  color: #333333;
}

.skill-highlight-link {
  color: #043aff;
  font-weight: 700;
  text-decoration: underline;
  text-decoration-skip-ink: none;
  text-underline-position: from-font;
}

.skill-highlight-link:focus-visible,
.skill-highlights-track:focus-visible {
  outline: 2px solid #043aff;
  outline-offset: 3px;
}

/* Accessible fallback: no pin, no JS-driven transform — plain, unpinned
   native horizontal scroll instead (the section's original interaction
   before the scroll-scrub track existed), per spec: "고정 및 과도한 이동을
   완화한 접근 가능한 대체 동작". initSkillHighlightsScrub() checks this
   same query and skips straight to this path, never starting the rAF
   loop or attaching scroll/resize listeners at all. */
/* 모바일에도 이 정적 대체 화면을 그대로 쓴다. 데스크톱(모션 허용)에서는
   두 조건 모두 거짓이라 이 블록이 아예 적용되지 않는다. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  .skill-highlights-pin {
    height: auto;
  }

  .skill-highlights-sticky {
    position: relative;
    height: auto;
    min-height: 0;
  }

  .skill-highlights-track {
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    transform: none !important;
  }

  .skill-highlight-card {
    scroll-snap-align: start;
  }
}

/* ---------- 이메일 복사 토스트 ----------
   푸터 말풍선을 누르면 메일 앱을 여는 대신 주소를 클립보드에 넣고 이걸
   화면 하단에 잠깐 띄운다. position:fixed라 스크롤 위치와 무관하게 늘
   같은 자리에 뜨고, 문서 흐름을 밀지 않는다. */
.mail-copy-toast {
  position: fixed;
  left: 50%;
  bottom: clamp(24px, 4vh, 48px);
  z-index: 3000;
  transform: translate(-50%, 14px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 12px 22px;
  border-radius: 999px;
  background: rgba(16, 17, 20, 0.92);
  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.28);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  text-align: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 280ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}

.mail-copy-toast.is-shown {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* 복사가 실패한 환경(클립보드 권한 없음 등)에서는 주소를 직접 긁어갈 수
   있어야 하므로 그때만 포인터/선택을 허용한다. */
.mail-copy-toast.is-manual {
  pointer-events: auto;
  user-select: text;
}

.mail-copy-toast-title {
  font-weight: 600;
  font-size: clamp(13px, 1.05vw, 15px);
  line-height: 1.4;
  color: #ffffff;
  /* 좁은 화면에서 두 줄로 접히지 않도록 한 줄 고정. 데스크톱에서는
     이미 한 줄이라 보이는 변화가 없다. */
  white-space: nowrap;
}

.mail-copy-toast-mail {
  font-weight: 400;
  font-size: clamp(11px, 0.9vw, 13px);
  line-height: 1.4;
  color: rgba(255, 255, 255, 0.55);
}

@media (prefers-reduced-motion: reduce) {
  .mail-copy-toast {
    transition: opacity 160ms ease;
    transform: translate(-50%, 0);
  }
}

/* ==========================================================================
   모바일 전용 보정 (768px 이하)
   데스크톱은 이 블록을 아예 읽지 않으므로 영향이 없다.
   앞선 단계에서 핀(스크롤 고정) 연출은 정적 화면으로 대체했는데, 그중
   일부가 여전히 '데스크톱 좌표로 가로로 펼치는' 배치라 페이지가 화면보다
   넓어졌다. 페이지가 가로로 넘치면 width:100% 인 다른 섹션들까지 좁은
   컬럼처럼 보이므로(보고된 '검은 세로 막대' 증상), 넘침을 없애는 것이
   먼저다.
   ========================================================================== */

@media (max-width: 768px) {
  /* 페이지 전체를 1200px로 못 박던 min-width 해제.
     상단 `html, body { min-width: 1200px }` 때문에 390px 화면에서도 문서가
     1200px로 잡혔고, 그 결과 width:100vw 인 요소는 1200px 부모 안에서
     390px짜리 좁은 기둥이 됐다(보고된 '검은 세로 막대'의 정체).
     이게 남아 있던 모바일 깨짐의 단일 원인이다. */
  html,
  body {
    min-width: 0;
    max-width: 100%;
  }

  /* 가로 넘침 차단은 html 한 곳에서만, hidden 이 아니라 clip 으로.
     overflow:hidden 은 스크롤 컨테이너를 만들어 그 안의 position:sticky 를
     무력화한다(프로젝트 섹션이 고정되지 않고 그냥 흘러가던 원인). body 에도
     걸면 같은 문제가 되풀이되므로 body 에는 두지 않는다. 이 파일 위쪽
     .role-wall-section 주석에 같은 함정이 이미 기록돼 있다. */
  html {
    overflow-x: clip;
  }

  /* ---------- 경력(History) : 12장을 가로로 펼치던 배치를 2열 그리드로 ----------
     initHistoryIntro()의 정적 경로가 카드마다 translate(x,y)를 직접 써서
     11칸 폭(화면의 몇 배)으로 벌려 놓는다. 그 인라인 transform을 이기려면
     !important 가 필요하다. */
  .history-intro-pin {
    height: auto;
  }

  .history-intro-sticky {
    position: relative;
    height: auto;
    min-height: 0;
  }

  /* 2줄 가로 스와이프. 한 화면에 2장이 온전히 들어오고 3번째가 살짝
     걸치도록 카드 폭을 40vw로 잡았다(2*40 + 여백 ≈ 88vw). 세 번째가
     삐져나와 보이는 것이 '옆에 더 있다'는 유일한 신호이므로 의도적이다. */
  .history-intro-cards {
    position: relative;
    inset: auto;
    display: grid;
    grid-auto-flow: column;
    grid-template-rows: repeat(2, auto);
    grid-auto-columns: 40vw;
    gap: 8px;
    padding: 24px 16px 32px;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }

  .history-intro-cards::-webkit-scrollbar {
    display: none;
  }

  .history-intro-cards .history-card {
    position: relative;
    top: auto;
    left: auto;
    margin: 0;
    width: 100%;
    height: auto;
    min-height: 132px;
    padding: 14px 10px;
    border-radius: 10px;
    scroll-snap-align: start;
    opacity: 1 !important;
    transform: none !important;
  }

  .history-card-title {
    width: 100%;
    font-size: 12px;
    line-height: 1.35;
  }

  .history-card-meta,
  .history-card-date,
  .history-card-duration {
    font-size: 10px;
  }

  /* 가로로 흐르던 거대 숫자 트랙은 모바일에서 숨긴다(폭 2600px대). */
  .history-intro-giant,
  .history-intro-giant-track,
  .history-intro-state {
    display: none;
  }

  /* ---------- Skills : 가로 스크롤 줄을 세로 2열로 ---------- */
  .skill-highlights-track {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 16px;
    padding-inline: 16px;
    overflow-x: visible !important;
    transform: none !important;
  }

  .skill-highlights-track > * {
    width: 100% !important;
    max-width: 100%;
  }
}

/* ==========================================================================
   모바일 전용 보정 2차 (768px 이하)
   ========================================================================== */

@media (max-width: 768px) {
  /* ---------- Role Wall: 화면에 들어오면 자동 재생 ----------
     스크롤을 붙잡는 대신, 섹션이 보이면 검은 기둥이 스스로 펼쳐진다.
     JS(initRoleWallAutoPlay)가 .is-auto-in 을 붙이면 전환이 시작된다. */
  .role-wall-block {
    width: clamp(44px, 12vw, 84px);
    height: 62vh;
    transition: width 900ms cubic-bezier(0.22, 1, 0.36, 1),
      border-radius 900ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .role-wall-sticky.is-auto-in .role-wall-block {
    width: 100% !important;
    border-radius: 0;
  }

  .role-wall-message {
    transition: opacity 600ms ease 500ms;
  }

  .role-wall-sticky.is-auto-in .role-wall-message {
    opacity: 1 !important;
  }

  /* ---------- Skills: 1열 + 이미지/영상 화면 폭 100% ---------- */
  .skill-highlights-track {
    grid-template-columns: 1fr;
    gap: 40px;
    padding-inline: 0;
    width: 100%;
    max-width: 100vw;
  }

  .skill-highlight-card {
    width: 100% !important;
    max-width: 100%;
    gap: 12px;
  }

  .skill-highlight-media {
    width: 100%;
    max-width: 100%;
  }

  .skill-highlight-media img,
  .skill-highlight-media video {
    width: 100% !important;
    height: auto !important;
    max-width: 100%;
    display: block;
  }

  /* 미디어만 가장자리까지, 글은 안쪽 여백을 유지 */
  .skill-highlight-copy {
    padding-inline: 16px;
  }

  /* ---------- 히어로 아래 데스크 영상: 레터박스 제거 ----------
     stage 가 100dvh 고정이라 16:9 영상이 contain 되면서 위아래로 빈
     공간이 크게 남았다. 높이를 영상 비율에 맡긴다. */
  .skill-transition-stage {
    height: auto;
    min-height: 0;
  }

  .skill-transition-video {
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
}

/* ==========================================================================
   모바일 전용 보정 3차 (768px 이하)
   ========================================================================== */

@media (max-width: 768px) {
  /* ---------- 기본 좌우 여백 20px ----------
     섹션마다 제각각이던 값을 하나로 맞춘다. 미디어를 화면 끝까지 붙이는
     곳(Skills 이미지 등)만 아래에서 따로 0으로 되돌린다. */
  .feature-sticky,
  .skill-highlight-copy,
  .history-intro-cards,
  .role-wall-message {
    padding-inline: 20px;
  }

  .history-intro-cards {
    padding-block: 24px 32px;
  }

  /* ---------- Skills: 글자 키우고 항목 간격 넓히기 ----------
     기본 clamp의 하한(제목 12.8px / 본문 9.6px)이 데스크톱 축소용이라
     폰에서 지나치게 작다. 모바일에서는 읽는 크기로 고정한다. */
  .skill-highlights-track {
    gap: 72px;
  }

  .skill-highlight-title {
    font-size: 20px;
    line-height: 1.35;
  }

  .skill-highlight-desc {
    font-size: 15px;
    line-height: 1.6;
  }

  .skill-highlight-card {
    gap: 16px;
  }

  /* ---------- 히어로: 영상은 화면을 꽉 채우고(cover 유지), 문구는 좌하단 ----------
     contain 으로 바꿔 봤으나 위아래 여백이 생기는 쪽보다 꽉 찬 화면이 낫다는
     판단. 대신 인물과 글이 겹치지 않도록 제목·본문을 아래로 내린다. */
  .hero-section {
    align-items: flex-end;
    padding-right: 20px;
    padding-left: 20px;
    padding-bottom: clamp(48px, 12vh, 120px);
  }

  .hero-content {
    align-items: flex-start;
    text-align: left;
    width: 100%;
  }

  .hero-title,
  .hero-subtitle {
    text-align: left;
    width: 100%;
  }
}

@media (max-width: 768px) {
  /* ---------- 경력 24개: 좌측 여백 20px, 검정 배경이 아래까지 ---------- */
  .history-intro-section,
  .history-intro-sticky {
    background: #000000;
  }

  .history-intro-sticky {
    padding-bottom: 56px;
  }

  .history-intro-cards {
    padding-inline: 20px 0; /* 오른쪽은 카드가 이어지는 방향이라 0 */
  }

}

@media (max-width: 768px) {
  /* ---------- 경력 24개: 좌측 여백 더, 검정 더 아래까지 ---------- */
  .history-intro-cards {
    padding-inline: 28px 0;
    scroll-padding-left: 28px;
  }

  .history-intro-sticky {
    padding-bottom: 120px;
  }

  /* ---------- 히어로 아래 데스크 영상: 가운데 정렬 ---------- */
  .skill-transition-stage {
    display: flex;
    align-items: center;
    justify-content: center;
  }
}

@media (max-width: 768px) {
  /* ---------- Role Wall: 기둥이 벌어지는 모션을 그대로 보여준다 ----------
     섹션을 검정으로 칠하면 검정 기둥이 안 보여 모션이 사라진다. 섹션은
     흰색으로 두되, 끝 상태에서 기둥이 화면을 꽉 채우게 해 다음(검정)
     섹션과의 사이에 흰 띠가 남지 않도록 한다. */
  .role-wall-sticky.is-auto-in .role-wall-block {
    height: 100% !important;
  }

  .role-wall-message {
    opacity: 0 !important;
    transform: translateY(18px);
    transition: opacity 700ms cubic-bezier(0.22, 1, 0.36, 1),
      transform 700ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .role-wall-sticky.is-auto-in .role-wall-message {
    opacity: 1 !important;
    transform: translateY(0);
  }
}

@media (max-width: 768px) {
  /* ---------- Skills: 이미지도 좌우 20px 여백 안에서 꽉 차게 ----------
     앞서 미디어만 화면 끝까지 붙였는데, 다른 섹션과 여백이 어긋나 보인다.
     트랙에 20px을 주고 미디어는 그 안에서 100%를 차지한다. */
  .skill-highlights-track {
    padding-inline: 20px;
  }

  .skill-highlight-copy {
    padding-inline: 0;
  }

  .skill-highlight-media,
  .skill-highlight-media img,
  .skill-highlight-media video {
    width: 100% !important;
    max-width: 100%;
    border-radius: 8px;
  }

  /* ---------- 히어로 아래 데스크 영상: 세로를 조금 더 ----------
     원본이 16:9라 화면 폭을 채우면 높이가 219px밖에 안 된다. 좌우를 조금
     잘라내고 4:3으로 잡아 세로를 늘린다(cover). */
  .skill-transition-video {
    aspect-ratio: 4 / 3;
  }

  /* ---------- 가치관: 고정 프레임 안에서의 크기 ----------
     PC와 같은 핀 동작으로 되돌렸으므로, 좁은 화면에 맞는 크기만 잡는다. */
  .feature-left {
    width: 100%;
    height: auto;
    flex: 0 0 auto;
    justify-content: center;
  }

  /* .feature-img 가 absolute 라 부모가 높이를 못 잡는다 -> 명시적으로 준다. */
  .feature-images {
    position: relative;
    width: 100%;
    /* 아래 문구 상자가 커진 만큼(200 -> 232) 그림을 조금 줄여, 한 화면
       안에 그림+문구가 함께 들어가게 한다. */
    height: 31vh;
    max-height: 280px;
  }

  .feature-img {
    object-fit: contain;
  }

  .feature-right {
    flex: 0 0 auto;
    height: auto;
    /* 다섯 문장 중 가장 긴 것(03/04)에 맞춘 값. 200px 이던 시절엔 아래가 잘렸다. */
    min-height: 232px;
    width: 100%;
  }

  .feature-item {
    align-items: flex-start;
    text-align: left;
  }

  .feature-title {
    font-size: clamp(26px, 8vw, 36px);
  }

  .feature-desc {
    font-size: 15px;
    line-height: 1.65;
  }
}

@media (max-width: 768px) {
  /* ---------- Role Wall: 기둥 전환에 높이도 포함 ---------- */
  .role-wall-block {
    transition: width 900ms cubic-bezier(0.22, 1, 0.36, 1),
      height 900ms cubic-bezier(0.22, 1, 0.36, 1),
      border-radius 900ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  /* ---------- 푸터: 지구본 키우고, 위 여백 줄이기 ---------- */
  .footer-entry-gap {
    height: clamp(60px, 9vh, 110px);
  }

  .globe-stage {
    width: 150%;
  }

  /* ---------- 해시태그 3개: 세로 나열, 크게, 가운데 ----------
     JS가 세 개를 가로로 모으는데(gatherCoreKeywords), 좁은 화면에서는
     한 줄에 다 들어가지 않아 잘린다. 모바일에서는 배치를 CSS가 가져와
     세로로 쌓는다. transform 은 JS가 인라인으로 써서 !important 가 필요. */
  .char-keyword-overlay {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 14px;
    padding-inline: 20px;
  }

  .char-keyword-overlay .char-keyword {
    position: relative !important;
    left: auto !important;
    top: auto !important;
    transform: none !important;
    font-size: 19px !important;
    padding: 14px 22px !important;
    width: auto !important;
    height: auto !important;
    max-width: 100%;
    text-align: center;
    white-space: nowrap;
  }
}

/* 맨 위로 버튼 — 모바일에서만 노출. 데스크톱 화면에는 영향이 없다. */
/* 맨 위로 버튼 — 음소거 버튼 바로 위, 완전히 같은 모양.
   .bgm-toggle 이 right:24px / bottom:20px / 40x40 이므로 그 위로 12px 띄운다. */
.top-toggle {
  position: fixed;
  right: 24px;
  bottom: calc(72px + env(safe-area-inset-bottom));
  z-index: 500;
  width: 40px;
  height: 40px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid var(--color-border);
  background: rgba(255, 255, 255, 0.85);
  color: var(--color-text-900);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  /* 한 화면 이상 내려가야 나타난다. */
  opacity: 0;
  visibility: hidden;
  transform: translate3d(0, 8px, 0);
  transition: opacity 220ms ease, transform 220ms ease, visibility 220ms,
    background-color 220ms ease;
}

.top-toggle.is-shown {
  opacity: 1;
  visibility: visible;
  transform: translate3d(0, 0, 0);
}

.top-toggle:hover {
  background: var(--color-white);
}

.top-toggle svg {
  width: 18px;
  height: 18px;
  display: block;
}

@media (max-width: 768px) {
  /* ---------- Hero 딤: 좌측 → 하단 ----------
     모바일에서는 타이틀이 좌측이 아니라 하단에 있으므로 그라데이션도
     아래에서 위로 걷히도록 바꾼다. */
  .hero-section::after {
    background: linear-gradient(
      180deg,
      rgba(0, 0, 0, 0.12) 0%,
      rgba(0, 0, 0, 0) 34%,
      rgba(0, 0, 0, 0.42) 72%,
      rgba(0, 0, 0, 0.72) 100%
    );
  }

  /* ---------- 푸터 말풍선: 화면 정중앙에 ----------
     푸터 박스(= 화면 높이) 전체를 덮는 투명한 상자로 만들고 그 안에서
     세로 가운데 정렬한다. 상자 자체는 탭을 먹지 않도록 두고, 실제 링크만
     탭이 되게 한다(위쪽 프로젝트 링크가 가려지는 것을 막는다). */
  .footer-contact-reveal {
    position: absolute;
    left: 20px;
    right: 20px;
    top: 0;
    bottom: 0;
    align-self: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: auto;
    max-width: none;
    margin-bottom: 0;
    pointer-events: none !important;
  }

  .footer-contact-float {
    width: 100%;
  }

  .footer-contact-reveal.is-revealed .footer-contact-toast {
    pointer-events: auto;
  }

  /* ---------- 플로팅 버튼: 조금 크게 + 맨 위로 버튼 추가 ---------- */
  .bgm-toggle {
    width: 50px;
    height: 50px;
    /* 640px 이하 블록과 같은 값으로 못 박아, 641~768px 구간에서도 맨 위로
       버튼과 오른쪽 끝이 정확히 같은 선에 오게 한다. */
    right: 16px;
    bottom: calc(16px + env(safe-area-inset-bottom));
  }

  .bgm-toggle .bgm-icon {
    width: 22px;
    height: 22px;
  }

  /* 모바일은 음소거 버튼이 right:16px / bottom:16px / 50x50 이므로
     위치와 크기만 다시 잡는다(모양은 위 기본 규칙 그대로 쓴다). */
  .top-toggle {
    right: 16px;
    bottom: calc(78px + env(safe-area-inset-bottom));
    width: 50px;
    height: 50px;
  }

  .top-toggle svg {
    width: 22px;
    height: 22px;
  }
}
