/* CityFlow — S5 디지털트윈 (실증 구역 등각 도시뷰 + 시나리오)
   소유: S5 워커 */

/* ── 루트 ──────────────────────────────────────────────────── */
.twin {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: stretch;
}

/* ── 부팅 로딩 오버레이 (SC-16 · 계약서 §5-2) ────────────────
   엑스포 부스에서 관람객이 처음 마주하는 화면이다. 빈 화면 끝에 지도가 튀어나오는 대신
   준비 과정 자체를 시연의 도입부로 보여준다 — 브랜드 · 진행률 · 단계 체크리스트.

   position:absolute 는 장식이 아니라 필수다. 이 요소는 .twin(display:flex) 의 첫 자식이라
   static 이면 문서 흐름에 끼어들어 지도를 밀어낸다(테크리드 실측 180px).
   그래서 표시 여부는 display 가 아니라 data-state 와 opacity 가 관장한다.

   상태별 성격
     boot     — 아직 진행률이 없다. 결정형 막대 대신 왕복 스캔으로 "살아 있음"만 말한다
     loading  — 결정형 진행률. 단계 목록이 하나씩 쌓인다
     ready    — 색조가 정상 상태색으로 넘어간다. 스캔이 멎고 준비 완료가 확정된다
     fallback — 색조가 경고색으로. 폴백 사유(__note)가 열린다. 그래도 반드시 걷힌다
     done     — 페이드 아웃. 지도가 뒤에서 이미 준비된 채로 드러난다

   색조는 --boot-tone 하나로 몬다. 마크 · 진행바 · 단계 링 · 스캔이 같은 값을 보고
   움직이므로 국면이 바뀌면 화면 전체가 한 번에 갈린다. */
.twin-boot {
  position: absolute;
  inset: 0;
  z-index: 38;
  display: grid;
  place-items: center;
  padding: var(--sp-6);
  --boot-tone: var(--accent);
  background:
    radial-gradient(ellipse 62% 52% at 50% 42%, color-mix(in srgb, var(--boot-tone) 11%, transparent), transparent 74%),
    radial-gradient(ellipse 100% 92% at 50% 50%, var(--bg), var(--bg-deep) 82%);
  transition: opacity 620ms var(--ease-enter), visibility 0s linear 0s;
}
.twin-boot[hidden] { display: none; }
.twin-boot[data-state="ready"]    { --boot-tone: var(--st-running); }
.twin-boot[data-state="fallback"] { --boot-tone: var(--st-warn); }
.twin-boot[data-state="done"] {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 620ms var(--ease-enter), visibility 0s linear 620ms;
}

.twin-boot__inner {
  position: relative;
  overflow: hidden;
  width: min(420px, 100%);
  display: grid;
  gap: var(--sp-5);
  padding: var(--sp-6) var(--sp-5) var(--sp-5);
  /* 얕은 기술 그리드 — 정지 텍스처라 구동 비용이 없고, 빈 유리판이 아니라
     계측 화면으로 읽힌다. 지도가 아직 없는 순간을 이 격자가 대신 채운다. */
  background:
    repeating-linear-gradient(0deg,   color-mix(in srgb, var(--text-faint) 6%, transparent) 0 1px, transparent 1px 28px),
    repeating-linear-gradient(90deg,  color-mix(in srgb, var(--text-faint) 6%, transparent) 0 1px, transparent 1px 28px),
    var(--surface-glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  backdrop-filter: blur(14px);
  /* fill-mode 를 주지 않는다 — 애니메이션이 끝난 뒤 선언값으로 돌아와야
     data-state="done" 의 transform 트랜지션이 먹는다 */
  animation: twin-boot-in 520ms var(--ease-enter);
  transition: transform 620ms var(--ease-enter), opacity 620ms var(--ease-enter);
}
.twin-boot[data-state="done"] .twin-boot__inner { transform: scale(0.985); opacity: 0.35; }
@keyframes twin-boot-in {
  from { opacity: 0; transform: translateY(10px) scale(0.985); }
  to   { opacity: 1; transform: none; }
}

/* 패널 상단 스캔 — 스테이지 단계 스윕(.twin-stage::after)과 같은 어휘를 쓴다.
   준비가 끝나면(ready·done) 멎는다: 아직 도는 스캔은 "아직 안 끝났다"로 읽히기 때문이다. */
.twin-boot__inner::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 42%;
  height: 2px;
  background: linear-gradient(90deg, transparent, var(--boot-tone), transparent);
  animation: twin-boot-sweep 2.6s linear infinite;
}
.twin-boot[data-state="ready"] .twin-boot__inner::before,
.twin-boot[data-state="done"] .twin-boot__inner::before { animation: none; opacity: 0; }
@keyframes twin-boot-sweep {
  from { transform: translateX(-100%); }
  to   { transform: translateX(238%); }
}

.twin-boot__brand {
  display: grid;
  justify-items: center;
  gap: var(--sp-2);
  text-align: center;
}
.twin-boot__mark {
  width: 58px;
  height: 58px;
  padding: 13px;
  color: var(--boot-tone);
  border: 1px solid color-mix(in srgb, var(--boot-tone) 32%, transparent);
  border-radius: var(--radius-lg);
  background: color-mix(in srgb, var(--boot-tone) 12%, transparent);
  filter: drop-shadow(0 0 18px color-mix(in srgb, var(--boot-tone) 40%, transparent));
  animation: twin-boot-mark 2.4s var(--ease-micro) infinite;
  transition: color 420ms var(--ease-micro);
}
.twin-boot[data-state="ready"] .twin-boot__mark,
.twin-boot[data-state="done"] .twin-boot__mark { animation: none; opacity: 1; }
@keyframes twin-boot-mark {
  0%, 100% { opacity: 0.55; }
  50%      { opacity: 1; }
}
.twin-boot__product {
  margin-top: var(--sp-1);
  font-size: var(--fs-h2);
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
}
.twin-boot__zone {
  font-size: var(--fs-body);
  letter-spacing: 0.06em;
  color: var(--text-dim);
}

/* 진행바 — 폭은 JS가 인라인으로 준다(계약상 유일한 예외). 나머지는 전부 여기서 결정한다 */
.twin-boot__progress {
  position: relative;
  overflow: hidden;
  height: 4px;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--text-faint) 26%, transparent);
}
.twin-boot__fill {
  display: block;
  width: 0;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, color-mix(in srgb, var(--boot-tone) 40%, transparent), var(--boot-tone));
  box-shadow: 0 0 12px color-mix(in srgb, var(--boot-tone) 55%, transparent);
  transition: width 420ms var(--ease-enter), background 420ms var(--ease-micro);
}
/* 진행률이 아직 없는 국면 — 왕복 스캔이 결정형 막대를 대신한다 */
.twin-boot[data-state="boot"] .twin-boot__progress::after {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 34%;
  border-radius: inherit;
  opacity: 0.8;
  background: linear-gradient(90deg, transparent, var(--boot-tone), transparent);
  animation: twin-boot-scan 1.5s var(--ease-micro) infinite;
}
@keyframes twin-boot-scan {
  from { transform: translateX(-110%); }
  to   { transform: translateX(400%); }
}

/* 단계 체크리스트 — 목록이 아니라 순서로 읽혀야 하므로 링을 세로 하이라인으로 잇는다 */
.twin-boot__steps { display: grid; }
.twin-boot__steps:empty { display: none; }
.twin-boot__step {
  position: relative;
  display: grid;
  grid-template-columns: 16px 1fr auto;
  align-items: center;
  gap: var(--sp-3);
  padding: 5px 0;
  animation: twin-boot-step-in 320ms var(--ease-enter) both;
}
@keyframes twin-boot-step-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}
.twin-boot__step + .twin-boot__step::before {
  content: "";
  position: absolute;
  left: 7.5px;
  bottom: 50%;
  width: 1px;
  height: calc(100% - 10px);
  background: color-mix(in srgb, var(--text-faint) 38%, transparent);
  transition: background 300ms var(--ease-micro);
}
.twin-boot__step[data-state="active"]::before,
.twin-boot__step[data-state="done"]::before { background: color-mix(in srgb, var(--boot-tone) 60%, transparent); }

.twin-boot__step-dot {
  position: relative;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 1.5px solid color-mix(in srgb, var(--text-faint) 55%, transparent);
  transition: border-color 300ms var(--ease-micro), background 300ms var(--ease-micro);
}
.twin-boot__step[data-state="wait"] .twin-boot__step-dot { opacity: 0.7; }

/* 진행 — 코어 + 퍼지는 링. 동시에 active 인 단계는 하나뿐이라 무한 애니메이션도 하나다 */
.twin-boot__step[data-state="active"] .twin-boot__step-dot {
  border-color: var(--boot-tone);
  background: radial-gradient(circle at 50% 50%, var(--boot-tone) 0 34%, transparent 42%);
}
.twin-boot__step[data-state="active"] .twin-boot__step-dot::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  border: 1.5px solid var(--boot-tone);
  animation: twin-boot-dot 1.4s var(--ease-micro) infinite;
}
@keyframes twin-boot-dot {
  0%   { transform: scale(0.7); opacity: 0.8; }
  100% { transform: scale(1.7); opacity: 0; }
}

/* 완료 · 실패 표식은 CSS로 그린다 — 아이콘 폰트·그림문자를 쓰지 않는다 */
.twin-boot__step[data-state="done"] .twin-boot__step-dot {
  border-color: color-mix(in srgb, var(--st-running) 70%, transparent);
  background: color-mix(in srgb, var(--st-running) 16%, transparent);
}
.twin-boot__step[data-state="done"] .twin-boot__step-dot::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 45%;
  width: 4px;
  height: 7.5px;
  border: solid var(--st-running);
  border-width: 0 1.7px 1.7px 0;
  transform: translate(-50%, -50%) rotate(42deg);
}
.twin-boot__step[data-state="fail"] .twin-boot__step-dot {
  border-color: color-mix(in srgb, var(--st-error) 75%, transparent);
  background: color-mix(in srgb, var(--st-error) 18%, transparent);
}
.twin-boot__step[data-state="fail"] .twin-boot__step-dot::before,
.twin-boot__step[data-state="fail"] .twin-boot__step-dot::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 8px;
  height: 1.7px;
  border-radius: 1px;
  background: var(--st-error);
}
.twin-boot__step[data-state="fail"] .twin-boot__step-dot::before { transform: translate(-50%, -50%) rotate(45deg); }
.twin-boot__step[data-state="fail"] .twin-boot__step-dot::after  { transform: translate(-50%, -50%) rotate(-45deg); }

.twin-boot__step-label {
  font-size: var(--fs-sm);
  color: var(--text-faint);
  transition: color 300ms var(--ease-micro);
}
.twin-boot__step[data-state="active"] .twin-boot__step-label { color: var(--text); font-weight: 600; }
.twin-boot__step[data-state="done"] .twin-boot__step-label { color: var(--text-dim); }
.twin-boot__step[data-state="fail"] .twin-boot__step-label { color: var(--st-error); }

/* 소요 시간은 계기 읽음값이다 — 등폭 + tabular-nums 로 자릿수가 흔들리지 않게 한다 */
.twin-boot__step-meta {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
  font-size: var(--fs-xs);
  color: var(--text-faint);
}
.twin-boot__step[data-state="done"] .twin-boot__step-meta { color: color-mix(in srgb, var(--st-running) 70%, var(--text-faint)); }
.twin-boot__step[data-state="fail"] .twin-boot__step-meta { color: var(--st-error); }

.twin-boot__status {
  min-height: 1.5em;
  font-size: var(--fs-sm);
  color: var(--text-dim);
  text-align: center;
  transition: color 300ms var(--ease-micro);
}
.twin-boot[data-state="ready"] .twin-boot__status { color: var(--st-running); font-weight: 600; }
.twin-boot[data-state="fallback"] .twin-boot__status { color: var(--st-warn); }

.twin-boot__note {
  padding: var(--sp-2) var(--sp-3);
  border-left: 2px solid var(--st-warn);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--st-warn) 12%, transparent);
  font-size: var(--fs-xs);
  line-height: 1.5;
  color: var(--text-dim);
}
.twin-boot__note[hidden] { display: none; }

/* light 테마 — 밝은 배경에서 글로우는 번짐으로 읽힌다. 대신 패널에 그림자를 줘
   오버레이가 지도 위에 떠 있다는 층위를 유지한다. */
[data-theme="light"] .twin-boot__mark { filter: none; }
[data-theme="light"] .twin-boot__fill { box-shadow: none; }
[data-theme="light"] .twin-boot__inner { box-shadow: 0 10px 32px var(--overlay-scrim); }

/* ── 스테이지 (등각 지도) ──────────────────────────────────── */
.twin-stage {
  position: relative;
  flex: 1 1 auto;
  overflow: hidden;
  /* 하단 겹침 정리 (SC-20) — 중앙 하단은 알림 배너와 시청 복귀 버튼이, 좌측 하단은
     시나리오 도크와 119 추적 버튼이 같은 자리를 놓고 다툰다.
     상시 컨트롤(카메라 버튼)을 화면 가장자리에 고정하고 임시 표시(배너)와
     큰 패널(도크)이 그 위로 올라서는 배치다. 버튼 높이가 바뀌어도 세 요소가
     같이 따라오도록 오프셋을 여기 한 곳에 모았다. */
  --twin-cambtn-h: 36px;
  --twin-bottom-lift: calc(var(--twin-cambtn-h) + var(--sp-3));
  background:
    radial-gradient(ellipse 70% 60% at 50% 38%, rgba(var(--accent-rgb), 0.06), transparent 70%),
    radial-gradient(ellipse 90% 80% at 50% 50%, var(--bg), var(--bg-deep) 84%);
  user-select: none;
}

/* 진행 단계(phase) 색 — 스테이지 전역 톤과 단계 리스트가 같은 맵을 쓴다.
   detect는 이상 감지의 날카로움, analyze는 연산, assign은 시스템 확정,
   dispatch는 이동, work는 현장 작업, report는 산출, return은 정상 복귀. */
.twin-stage,
.twin-step { --cf-phase: var(--accent); }
.twin-stage[data-phase="detect"],   .twin-step[data-phase="detect"]   { --cf-phase: var(--st-error); }
.twin-stage[data-phase="analyze"],  .twin-step[data-phase="analyze"]  { --cf-phase: var(--cat-facility); }
.twin-stage[data-phase="assign"],   .twin-step[data-phase="assign"]   { --cf-phase: var(--accent); }
.twin-stage[data-phase="dispatch"], .twin-step[data-phase="dispatch"] { --cf-phase: var(--cat-traffic); }
.twin-stage[data-phase="work"],     .twin-step[data-phase="work"]     { --cf-phase: var(--st-warn); }
.twin-stage[data-phase="report"],   .twin-step[data-phase="report"]   { --cf-phase: var(--cat-energy); }
.twin-stage[data-phase="return"],   .twin-step[data-phase="return"]   { --cf-phase: var(--st-running); }

/* 단계 톤은 지도를 가리면 안 된다 — 중앙은 건드리지 않고 가장자리 비네트와
   상단 스캔라인 2px 만으로 단계 전환을 알린다. */
.twin-stage::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 9;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(ellipse 118% 98% at 50% 52%, transparent 54%, var(--cf-phase) 190%);
  background: radial-gradient(ellipse 118% 98% at 50% 52%, transparent 54%, color-mix(in srgb, var(--cf-phase) 32%, transparent) 100%);
  /* background 전이는 합성 불가라 단계 전환마다 스테이지 전면을 700ms 동안 매 프레임 재페인트한다(R10 성능 감사 #7) — opacity 만 전이 */
  transition: opacity 700ms var(--ease-enter);
}
.twin-stage[data-phase]::before { opacity: 0.55; }

.twin-stage::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  z-index: 9;
  width: 38%;
  height: 2px;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(90deg, transparent, var(--cf-phase), transparent);
  transition: opacity 420ms var(--ease-micro);
}
.twin-stage[data-phase]::after {
  opacity: 0.7;
  animation: twin-phase-sweep 4.2s linear infinite;
}
@keyframes twin-phase-sweep {
  from { transform: translateX(-100%); }
  to   { transform: translateX(263%); }
}
[data-theme="light"] .twin-stage[data-phase]::before { opacity: 0.32; }

/* ── Cesium(브이월드) 3D 지도 캔버스 — 지형·건물·배경 위성영상 담당 ──
   기기 마커·연결선·HUD는 그 위에 얹히는 DOM/SVG 오버레이(기존 등각뷰와 동일 구조),
   화면좌표는 매 프레임 Cesium 투영으로 갱신된다(js/twin.js). */
.twin-cesium {
  position: absolute;
  inset: 0;
  z-index: 1;
}
.twin-cesium .cesium-widget canvas { outline: none; }
.twin-cesium-credit { display: none; }

.twin-links {
  position: absolute;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.twin-landmark-label {
  position: absolute;
  z-index: 6;
  transform: translate(-50%, -100%);
  padding: 3px 8px;
  background: var(--surface-glass);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--text-dim);
  white-space: nowrap;
  pointer-events: none;
  backdrop-filter: blur(6px);
}

/* 권역/이벤트 하이라이트 (occurredIn 단계)
   stroke-width·dasharray는 화면 픽셀이다 — #twin-links SVG에 viewBox가 없어
   등각뷰 시절의 좌표계 값(0.35 등)은 픽셀로 해석돼 보이지 않는다. */
.twin-zone {
  fill: none;
  stroke: var(--st-warn);
  stroke-width: 2;
  stroke-dasharray: 7 5;
  opacity: 0;
  transition: opacity var(--t-enter);
}
.twin-zone.is-on { opacity: 0.75; }

/* 관계 연결선 (occurredIn·relatedDrone 등)
   SC-21로 흐름 궤적이 연속 빔이 되면서 이 선만 점선으로 남으면 같은 화면에서
   두 가지 연결 어휘가 충돌한다 — 굵기를 올리고 대시를 걷어 빔 계열로 통일했다.
   흐르는 대시 대신 느린 밝기 호흡으로 "이 관계가 지금 살아 있다"를 말한다
   (활성 관계선은 보통 1~2개라 무한 애니메이션 총량에 부담이 없다). */
.twin-link {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2.2;
  stroke-linecap: round;
  opacity: 0;
  transition: opacity var(--t-enter);
}
.twin-link.is-on {
  opacity: 0.9;
  filter: drop-shadow(0 0 7px color-mix(in srgb, var(--accent) 42%, transparent));
  animation: twin-link-flow 2.6s var(--ease-micro) infinite;
}
[data-theme="light"] .twin-link.is-on { filter: none; }

@keyframes twin-link-flow {
  0%, 100% { stroke-opacity: 0.55; }
  50%      { stroke-opacity: 1; }
}

/* 상시 배경 데이터 흐름 — 각 기기 → 관제센터(허브)로 흐르는 은은한 텔레메트리.
   기기 수만큼(15개) 깔리는 레이어라 여기에 무한 대시 애니메이션을 걸면
   24시간 구동 내내 15개가 돈다. 연속 하이라인으로 바꿔 애니메이션을 걷어냈다 —
   움직임은 실제로 오가는 흐름 궤적이 맡고, 이 층은 "상시 연결됨"만 조용히 말한다. */
.twin-ambient {
  fill: none;
  stroke: var(--text-faint);
  stroke-width: 1.1;
  stroke-linecap: round;
  opacity: 0.2;
}
[data-theme="light"] .twin-ambient { opacity: 0.28; }

/* 흐름선 위에 고정으로 얹히는 데이터 라벨 — 실제로 오가는 값(속성명)을 표시.
   날아가는 배지가 아니라 선 위의 한 지점에 머무르며 내용만 갱신된다. */
.twin-linelabel {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 5;
  transform: translate(-50%, -50%);
  padding: 1px 6px;
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: var(--text-faint);
  background: var(--surface-glass);
  border: 1px solid var(--border);
  pointer-events: none;
  opacity: 0;
  transition: opacity 240ms var(--ease-micro), color 200ms var(--ease-micro), background 200ms var(--ease-micro), border-color 200ms var(--ease-micro);
}
/* 배경 텔레메트리 라벨 — 8개 회선을 한꺼번에 띄우지 않고 소수만 교대로 노출해 혼잡을 줄인다 */
.twin-linelabel.is-visible { opacity: 0.85; }
.twin-linelabel.is-visible.is-fade { opacity: 0; }
.twin-linelabel[hidden] { display: none; }

/* 시나리오 관계선(occurredIn·relatedDrone 등) 라벨 — 평소엔 숨겨져 있다가 단계가 활성화되면 노출 */
.twin-linelabel--rel.is-on { opacity: 1; color: var(--accent); background: var(--accent-soft); border-color: rgba(var(--accent-rgb), 0.4); }

/* 여러 회선이 동시에 관제센터와 통신하는 순간의 라벨 강조 */
.twin-linelabel.is-burst {
  z-index: 7;
  padding: 2px 8px;
  font-size: 10px;
  color: var(--cf-tone, var(--accent));
  background: var(--surface-glass-strong);
  border: 1.5px solid var(--cf-tone, var(--accent));
  box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.25);
  opacity: 1;
}

/* ── 데이터 흐름 FX (js/twin-fx.js가 생성하는 DOM) ──────────
   구조: 궤적(#twin-fx 안 SVG path) + 패킷/라벨(#twin-fxlayer 안 span).
   위치·opacity는 JS가 매 프레임 인라인으로 준다 — 여기서 opacity를 고정하거나
   left/top에 transition을 걸면 좌표가 뒤늦게 따라와 궤적이 어긋난다.
   #twin-fx SVG에는 viewBox가 없어 stroke-width가 화면 픽셀로 해석된다.

   2라운드 전환(SC-21) — 균일 점선 대시를 버리고 연속 빔으로 간다.
   대시 리듬이 사라지면 판독 축이 하나 줄어든다. 그 몫을 굵기(--tfx-w)·채도(--tfx-a)·
   블룸 겹수·패킷 형태 네 축에 다시 나눠 실었고, 한 시나리오에서 동시에 뜨는 조합끼리는
   최소 두 축 이상 벌려 놓았다. 특히 assign(3.8px·100%·2겹 블룸·14px 발광 코어)과
   release(1.2px·36%·무블룸·6px 저채도)는 네 축이 전부 반대로 간다 —
   "두 기기에 타진했다가 하나만 확정되고 하나는 풀렸다"가 원거리에서 갈려야 하기 때문이다.

   방향감은 진행각이 만든다. js/twin-fx.js 가 2차 베지어 접선각을 --tfx-ang,
   패킷 진행률을 --tfx-p, 잔상 감쇠를 --tfx-fade, 임팩트 수명을 --tfx-t,
   흐름별 그라디언트 paint 참조를 --tfx-beam 으로 인라인 전달한다(js/twin-fx.js:16-19).
   1라운드에서 꼬리를 포기했던 제약 — 각도 없이 늘린 형태는 세로 궤적에서 거꾸로 읽힌다 —
   이 값들로 풀린다. 여기서부터 패킷은 진행 방향으로 눕고 뒤로 감쇠하는 꼬리를 단다.

   레이어 3겹 (뒤 → 앞)
     .tfx-path   코어 — 흐름이 열리면 출발지에서 목적지로 그려지며 이어진다(대시 오프셋)
     .tfx-beam   서지 — 코어보다 굵은 밝은 구간이 선을 따라 훑는다. 굵기 굴곡이 이동으로 읽힌다
     .tfx-impact 도착 링 · .tfx-link 선택 연결선

   코어까지 그라디언트로 칠하지 않는 이유: 그라디언트 base 알파가 0.12 라
   띠가 지나가지 않는 구간이 사실상 사라진다. 원거리 판독이 우선이므로
   "항상 보이는 코어 + 그 위를 흐르는 빔"으로 역할을 나눴다.

   유형별 색은 토큰 조합이다. ack의 환경색(--cat-env)과 assign의 정상 상태색
   (--st-running)은 토큰값이 같아 그대로 쓰면 구분이 안 된다 — ack만 교통색을 섞어
   청록으로 민다. request와 handover는 둘 다 --accent 인데, 고정 카테고리색을 섞는
   분리안은 amber 테마에서 탁한 갈색이 되어 5종 전부에서 성립하지 않는다.
   그래서 색이 아니라 굵기 2.4/3.4px · 블룸 1겹/2겹 · 패킷 비드/캡슐로 가른다. */
.twin-fx {
  position: absolute;
  inset: 0;
  z-index: 3;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.twin-fxlayer {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}

/* 유형 정의 — 색(--tfx-c) · 채도(--tfx-a) · 빔 굵기(--tfx-w)
   --tfx-w 하나가 지도 위 stroke-width 와 범례 스와치 막대 두께를 동시에 몬다.
   범례와 실물이 어긋나면 범례가 거짓말을 하게 되므로 값을 나눠 갖지 않는다. */
.tfx-path--request,  .tfx-label--request,
  .tfx-grad--request, .tfx-beam--request, .tfx-impact--request,  .twin-flowlegend__item[data-kind="request"]
  { --tfx-c: var(--accent);       --tfx-a: 82%;  --tfx-w: 2.4px; }
.tfx-path--probe,    .tfx-label--probe,
  .tfx-grad--probe, .tfx-beam--probe, .tfx-impact--probe,    .twin-flowlegend__item[data-kind="probe"]
  { --tfx-c: var(--cat-facility); --tfx-a: 68%;  --tfx-w: 1.8px; }
.tfx-path--ack,      .tfx-label--ack,
  .tfx-grad--ack, .tfx-beam--ack, .tfx-impact--ack,      .twin-flowlegend__item[data-kind="ack"]
  { --tfx-c: var(--cat-env);      --tfx-a: 72%;  --tfx-w: 1.9px; }
.tfx-path--assign,   .tfx-label--assign,
  .tfx-grad--assign, .tfx-beam--assign, .tfx-impact--assign,   .twin-flowlegend__item[data-kind="assign"]
  { --tfx-c: var(--st-running);   --tfx-a: 100%; --tfx-w: 3.8px; }
.tfx-path--release,  .tfx-label--release,
  .tfx-grad--release, .tfx-beam--release, .tfx-impact--release,  .twin-flowlegend__item[data-kind="release"]
  { --tfx-c: var(--st-paused);    --tfx-a: 62%;  --tfx-w: 1.6px; }
.tfx-path--report,   .tfx-label--report,
  .tfx-grad--report, .tfx-beam--report, .tfx-impact--report,   .twin-flowlegend__item[data-kind="report"]
  { --tfx-c: var(--cat-energy);   --tfx-a: 84%;  --tfx-w: 2.3px; }
.tfx-path--alert,    .tfx-label--alert,
  .tfx-grad--alert, .tfx-beam--alert, .tfx-impact--alert,    .twin-flowlegend__item[data-kind="alert"]
  { --tfx-c: var(--st-error);     --tfx-a: 100%; --tfx-w: 3.2px; }
.tfx-path--handover, .tfx-label--handover,
  .tfx-grad--handover, .tfx-beam--handover, .tfx-impact--handover, .twin-flowlegend__item[data-kind="handover"]
  { --tfx-c: var(--accent);       --tfx-a: 100%; --tfx-w: 3.4px; }

/* ack만 계열 유지 + 색상각 분리 (assign과 토큰값이 같아 생기는 충돌 회피) */
.tfx-path--ack, .tfx-label--ack,
  .tfx-grad--ack, .tfx-beam--ack, .tfx-impact--ack, .twin-flowlegend__item[data-kind="ack"] {
  --tfx-c: color-mix(in srgb, var(--cat-env) 60%, var(--cat-traffic));
}

/* ── 서브레이어 — js/twin-fx.js 가 그리기 순서를 엘리먼트가 아니라 컨테이너로 고정한다.
   풀에서 꺼낸 엘리먼트는 순서가 뒤섞이므로 z 순서를 DOM 순서에 맡길 수 없다.
   네 겹을 전부 같은 좌표계(inset:0)에 겹쳐 두면 자식의 left/top 이 어긋나지 않는다. */
.tfx-sub { position: absolute; inset: 0; pointer-events: none; }

/* ── 서지 — 코어 위를 훑고 지나가는 굵고 밝은 구간 ────────────
   JS 가 stroke-dasharray/offset 으로 궤적의 15% 짜리 대시 하나를 만들어 선단 뒤에 붙인다
   (js/twin-fx.js 의 SURGE_SEG). 그래서 이 규칙은 "떠다니는 점"이 아니라 선 자체의 한 구간을 칠한다.
   코어보다 확실히 굵어야 굵기 굴곡이 선을 타고 흐르는 것으로 읽힌다 —
   구 방식(원형 패킷 + 잔상 점)을 대체한 표현이다.
   linecap: round 가 대시 양끝을 둥글려 구간이 뾰족하게 잘리지 않게 한다.
   그라디언트는 흐름마다 하나씩 만들어지고 축이 궤적 양끝에 맞춰진다.
   stop 색은 currentColor 라 여기서 유형색을 넘겨야 칠이 산다. */
.tfx-grad { color: var(--tfx-c, var(--accent)); }

.tfx-beam {
  fill: none;
  stroke: var(--tfx-beam, none);
  stroke-width: calc(var(--tfx-w, 2.4px) * 2.8);
  stroke-linecap: round;
  stroke-opacity: 0.86;
}
/* 결정적 흐름일수록 굵게 부풀며 지나가고, 물러나는 흐름은 흔적만 남긴다 */
.tfx-beam--assign,
.tfx-beam--alert    { stroke-width: calc(var(--tfx-w) * 3.2); stroke-opacity: 0.95; }
.tfx-beam--handover { stroke-width: calc(var(--tfx-w) * 3);   stroke-opacity: 0.9; }
.tfx-beam--release  { stroke-width: calc(var(--tfx-w) * 1.8); stroke-opacity: 0.4; }
[data-theme="light"] .tfx-beam { stroke-opacity: 0.72; }
[data-theme="light"] .tfx-beam--release { stroke-opacity: 0.42; }

/* ── 도착 임팩트 — 패킷이 목적지에 닿는 순간의 링 ────────────
   "전달됐다"가 읽히는 유일한 신호다. release 만 JS가 아예 만들지 않는다
   (js/twin-fx.js:48-58) — 확정은 목적지에서 터지고 해제는 조용히 물러나야
   "하나는 확정, 하나는 풀렸다"가 한눈에 갈린다.
   수명 --tfx-t(0→1)와 opacity 는 JS가 준다. CSS 애니메이션을 쓰지 않는 이유는
   풀 재사용 엘리먼트에서 재시작 트릭(reflow)이 프레임을 튀게 하기 때문이다. */
.tfx-impact {
  position: absolute;
  left: 0;
  top: 0;
  display: block;
  width: calc(6px + var(--tfx-w, 2.4px) * 2.4);
  aspect-ratio: 1;
  border-radius: 50%;
  border: 2px solid var(--tfx-c, var(--accent));
  /* 도착각 기준으로 살짝 눌러 놓는다 — 정원(正圓) 핑은 어디서 온 충격인지 말하지 않는다 */
  transform:
    translate(-50%, -50%)
    rotate(var(--tfx-ang, 0deg))
    scale(calc(0.3 + var(--tfx-t, 0) * 1.7), calc(0.3 + var(--tfx-t, 0) * 2.3));
  pointer-events: none;
}
/* 착지점의 코어 — 링이 퍼지는 동안 반대로 오므라들어 "여기에 닿았다"를 못박는다 */
.tfx-impact::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 60%;
  height: 60%;
  border-radius: 50%;
  background: var(--tfx-c, var(--accent));
  transform: translate(-50%, -50%) scale(calc(1 - var(--tfx-t, 0)));
}
.tfx-impact--assign { border-width: 2.5px; }
[data-theme="light"] .tfx-impact { border-width: 2px; }

/* ── 궤적 — 연속 빔 ───────────────────────────────────────── */
.tfx-path {
  fill: none;
  stroke: var(--tfx-c, var(--accent));
  stroke: color-mix(in srgb, var(--tfx-c, var(--accent)) var(--tfx-a, 82%), transparent);
  stroke-width: var(--tfx-w, 2.4px);
  stroke-linecap: round;
  /* 점화 — 흐름이 열리는 순간 빔이 가늘게 그어졌다가 한 번 부풀고 정착한다.
     twin-fx가 엘리먼트를 풀에서 재사용하며 class를 지웠다 다시 붙이므로
     재사용된 path에서도 매번 처음부터 재생된다(무한 반복 아님 — 24시간 구동 부담 없음). */
  animation: tfx-ignite 420ms var(--ease-enter);
}
/* 해제는 물러나는 흐름이라 점화하지 않는다 — 켜지는 연출이 붙으면 의미가 뒤집힌다 */
.tfx-path--release { animation: none; }

@keyframes tfx-ignite {
  0%   { stroke-width: 0.6px;                          stroke-opacity: 0.25; }
  55%  { stroke-width: calc(var(--tfx-w, 2.4px) * 1.5); stroke-opacity: 1; }
  100% { stroke-width: var(--tfx-w, 2.4px);             stroke-opacity: 1; }
}

/* 블룸은 확정·경보·인계·요청 넷만 — 전 유형에 filter를 걸면 화면이 뭉개지고
   filter 영역은 매 프레임 다시 래스터화되므로(3D 지도와 같은 GPU 예산) 개수를 묶는다.
   2겹(타이트 코어 + 넓은 산란)은 결정적 흐름에만 준다. */
.tfx-path--request {
  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--tfx-c) 45%, transparent));
}
.tfx-path--assign {
  filter:
    drop-shadow(0 0 3px color-mix(in srgb, var(--tfx-c) 75%, transparent))
    drop-shadow(0 0 14px color-mix(in srgb, var(--tfx-c) 45%, transparent));
}
.tfx-path--alert {
  filter:
    drop-shadow(0 0 3px color-mix(in srgb, var(--tfx-c) 65%, transparent))
    drop-shadow(0 0 12px color-mix(in srgb, var(--tfx-c) 42%, transparent));
}
.tfx-path--handover {
  filter:
    drop-shadow(0 0 4px color-mix(in srgb, var(--tfx-c) 55%, transparent))
    drop-shadow(0 0 13px color-mix(in srgb, var(--tfx-c) 38%, transparent));
}

/* light 테마 — 밝은 배경에서 블룸은 번짐으로 읽히고, 저채도 선은 아예 사라진다.
   글로우를 걷어내는 대신 채도를 끌어올려 원거리 판독을 유지한다. */
[data-theme="light"] .tfx-path--request,
[data-theme="light"] .tfx-path--assign,
[data-theme="light"] .tfx-path--alert,
[data-theme="light"] .tfx-path--handover { filter: none; }
[data-theme="light"] .tfx-path--request,
[data-theme="light"] .twin-flowlegend__item[data-kind="request"]  { --tfx-a: 100%; }
[data-theme="light"] .tfx-path--probe,
[data-theme="light"] .twin-flowlegend__item[data-kind="probe"]    { --tfx-a: 88%; }
[data-theme="light"] .tfx-path--ack,
[data-theme="light"] .twin-flowlegend__item[data-kind="ack"]      { --tfx-a: 92%; }
[data-theme="light"] .tfx-path--release,
[data-theme="light"] .twin-flowlegend__item[data-kind="release"]  { --tfx-a: 78%; }
[data-theme="light"] .tfx-path--report,
[data-theme="light"] .twin-flowlegend__item[data-kind="report"]   { --tfx-a: 100%; }

/* ── 선택 연결선 — 기기를 클릭했을 때 상시 연결 상대까지 깔리는 선 ──
   흐름(tfx-path/beam)과 목적이 다르다. 흐름은 "지금 이 데이터가 오갔다"는 사건이고,
   이 선은 "이 기기는 평소 이들과 이어져 있다"는 상태다. 그래서 유형색을 쓰지 않고
   무채색으로 깔아 흐름이 그 위를 지나갈 때 색 대비로 사건이 도드라지게 한다.
   (CEO 요구 "클릭 시 회색 선으로 연결해두고 은은하게 빛나게")
   filter 는 흐름 궤적과 달리 최대 5개까지만 붙는다 — renderLinkViz 가 peer 를 5개로 자른다. */
/* 그라디언트 stop 은 currentColor 를 쓴다 — 연결선은 유형색이 아니라 무채색이어야
   그 위를 지나는 흐름의 색이 사건으로 도드라진다.
   --text-dim 으로는 위성영상 위에서 사라져(CEO 3차 지적) 본문색까지 끌어올렸다.
   그라디언트 자체가 양끝 0.12 알파로 떨어지므로 최고 밝기만 올라가고 인상은 여전히 무채색이다. */
.tfx-grad--link { color: var(--text); }

/* ── 연결선 본선 ─────────────────────────────────────────────
   조정 이력: 1.7px/실효 0.29~0.54 → 안 보임 · 2.6px 단색 → 둔탁 · 1.5px 그라디언트 → 여전히 안 보임.
   결론은 굵기·밝기만으로는 "연결되어 있다"가 전달되지 않는다는 것이다.
   그래서 정적 표현을 버리고 두 가지 동작을 넣었다 —
     ① 클릭 순간 기기에서 상대 쪽으로 선이 뻗어나간다(대시 오프셋)
     ② 그 뒤로는 .tfx-link__pulse 가 선 위를 주기적으로 훑는다
   갈래마다 --tfx-delay 를 달리 줘 하나씩 이어지는 것으로 읽히게 한다. */
.tfx-link {
  fill: none;
  stroke: var(--tfx-linkpaint, var(--text));
  stroke-width: 2px;
  stroke-linecap: round;
  stroke-opacity: 1;
  filter:
    drop-shadow(0 0 1px rgba(0, 0, 0, 0.55))
    drop-shadow(0 0 8px color-mix(in srgb, var(--text) 42%, transparent));
  /* pathLength 가 1000 으로 정규화돼 있어 실제 궤적 길이와 무관하게 같은 속도로 그려진다 */
  stroke-dasharray: 1000 1000;
  animation: tfx-link-draw 640ms var(--ease-enter) var(--tfx-delay, 0ms) both;
}
@keyframes tfx-link-draw {
  from { stroke-dashoffset: 1000; }
  to   { stroke-dashoffset: 0; }
}

/* ── 연결선 반짝임 ───────────────────────────────────────────
   구 방식은 2.8초에 한 번 지나가는 «단발» 펄스라, 그 순간을 놓치면 선이 죽어 보였다(CEO 지적).
   짧은 대시를 선 전체에 반복해 깔고 그 패턴을 한 주기만큼 계속 흘려보낸다 —
   빛이 선을 따라 끊임없이 이동하는 것으로 읽힌다.
   점선처럼 보이지 않는 이유는 아래가 «불투명한 본선»이기 때문이다. 이 층은 그 위에 얹는 빛일 뿐이고,
   둥근 캡과 넓은 블러로 대시의 각이 지워져 막대가 아니라 반짝임이 된다.
   여기에 느린 밝기 호흡을 겹쳐 기계적인 행진이 아니라 은은한 일렁임으로 만든다. */
.tfx-link__pulse {
  fill: none;
  stroke: var(--tfx-linkpaint, var(--text));
  stroke-width: 3.2px;
  stroke-linecap: round;
  filter: drop-shadow(0 0 8px color-mix(in srgb, var(--text) 70%, transparent));
  /* 주기 46 (대시 9 + 간격 37) — pathLength 1000 기준 선 전체에 약 21개가 흐른다 */
  stroke-dasharray: 9 37;
  animation:
    tfx-link-shimmer 1.7s linear var(--tfx-delay, 0ms) infinite,
    tfx-link-glow 3.4s var(--ease-micro) var(--tfx-delay, 0ms) infinite;
}
/* 한 주기만큼만 흘리면 패턴이 이어져 끊김 없이 계속 이동한다 */
@keyframes tfx-link-shimmer {
  from { stroke-dashoffset: 0; }
  to   { stroke-dashoffset: -46; }
}

/* ── 역방향 층 ────────────────────────────────────────────────
   이 선이 말하는 것은 «연결되어 있다»는 관계이지 방향이 아니다 — 방향은 데이터 흐름이 말한다.
   한 방향으로만 흐르면 회색선이 스스로 방향을 주장해 흐름선과 의미가 겹친다(CEO 정의).
   음수 지연으로 위상을 반 주기 어긋내 두 흐름이 겹쳐 뭉치지 않게 하고,
   밝기 호흡도 한 주기 밀어 한쪽이 밝을 때 다른 쪽이 잦아들게 한다 — 오가는 것처럼 읽힌다. */
.tfx-link__pulse--rev {
  stroke-width: 2.8px;
  animation:
    tfx-link-shimmer-rev 1.7s linear calc(var(--tfx-delay, 0ms) - 0.85s) infinite,
    tfx-link-glow 3.4s var(--ease-micro) calc(var(--tfx-delay, 0ms) - 1.7s) infinite;
}
@keyframes tfx-link-shimmer-rev {
  from { stroke-dashoffset: -46; }
  to   { stroke-dashoffset: 0; }
}
@keyframes tfx-link-glow {
  0%, 100% { stroke-opacity: 0.38; }
  50%      { stroke-opacity: 0.72; }
}
[data-theme="light"] .tfx-grad--link { color: var(--text-dim); }
[data-theme="light"] .tfx-link,
[data-theme="light"] .tfx-link__pulse {
  /* 밝은 배경에서는 후광이 번짐으로 읽힌다 — 흰 분리선만 남긴다 */
  filter: drop-shadow(0 0 1.5px rgba(255, 255, 255, 0.85));
}

/* ── 흐름 라벨 — .twin-linelabel 계열의 모노 칩 ───────────── */
.tfx-label {
  position: absolute;
  left: 0;
  top: 0;
  padding: 1px 7px;
  border-radius: var(--radius-pill);
  font-family: var(--font-mono);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: var(--tfx-c, var(--accent));
  background: var(--surface-glass-strong);
  border: 1px solid var(--tfx-c, var(--accent));
  border-color: color-mix(in srgb, var(--tfx-c, var(--accent)) 62%, transparent);
  transform: translate(-50%, -50%);
  /* backdrop-filter 제거(R10 성능 감사 #4) — 배경 92% 불투명, 최대 14개가 매 프레임 이동 */
  pointer-events: none;
}
.tfx-label--assign,
.tfx-label--alert,
.tfx-label--handover { border-color: var(--tfx-c); }
.tfx-label--release { color: var(--text-faint); }

/* 선택한 기기의 연결 상대 — 선을 눈으로 따라가지 않아도 어느 기기들인지 즉시 읽히게 한다.
   회색선의 목적이 "이 기기가 무엇과 이어져 있는가"이므로(CEO 정의) 종점도 함께 표시한다.
   유형색이 아니라 연결선과 같은 무채색이라 시나리오 상태색(정상·점검·오류)과 겹치지 않는다. */
.twin-node.is-linked .twin-node__dot {
  box-shadow:
    0 0 0 2px color-mix(in srgb, var(--text) 55%, transparent),
    0 0 12px color-mix(in srgb, var(--text) 40%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
  .twin-node.is-linked .twin-node__halo { animation: twin-linked-halo 2.6s var(--ease-micro) infinite; }
}
@keyframes twin-linked-halo {
  0%, 100% { opacity: 0.25; transform: translate(-50%, -50%) scale(1); }
  50%      { opacity: 0.6;  transform: translate(-50%, -50%) scale(1.18); }
}

/* ── 기기 마커 레이어 ──────────────────────────────────────── */
.twin-devices { position: absolute; inset: 0; z-index: 4; pointer-events: none; }

.twin-node {
  position: absolute;
  width: 0;
  height: 0;
  padding: 0;
  background: none;
  border: 0;
  pointer-events: auto;
  transition: transform var(--dur-enter) var(--ease-enter);
}

.twin-node__shadow {
  position: absolute;
  left: 50%;
  top: 0;
  width: 26px;
  height: 9px;
  transform: translate(-50%, -50%);
  background: radial-gradient(ellipse, rgba(0, 0, 0, 0.38), transparent 72%);
  border-radius: 50%;
  pointer-events: none;
}
[data-theme="light"] .twin-node__shadow { background: radial-gradient(ellipse, rgba(20, 26, 40, 0.24), transparent 72%); }

.twin-node__mast {
  position: absolute;
  left: 50%;
  top: 0;
  width: 1px;
  height: 0;
  background: var(--border-strong);
  transform: translateX(-50%);
  pointer-events: none;
}

.twin-node__halo {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 46px;
  height: 46px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: radial-gradient(circle, var(--cf-color) 0%, transparent 68%);
  opacity: 0.3;
  pointer-events: none;
  transition: opacity 170ms var(--ease-micro);
}

.twin-node__dot {
  position: absolute;
  left: 50%;
  top: 50%;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--surface-glass-strong);
  border: 1.5px solid var(--cf-color);
  box-shadow: 0 0 0 1px var(--border), 0 0 14px rgba(var(--accent-rgb), 0.1);
  /* backdrop-filter 를 뺐다(R10 성능 감사 #3) — 배경이 92% 불투명이라 블러가 보이지 않는데,
     매 프레임 이동하는 노드 18개가 Cesium 캔버스 위에서 backdrop 스냅샷을 다시 계산하고 있었다 */
  transition: transform 170ms var(--ease-micro);
}
.twin-node__dot svg {
  width: 17px;
  height: 17px;
  stroke: var(--cf-color);
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.twin-node__badge {
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: 2px solid var(--bg);
}
.twin-node__badge[data-status="running"] { background: var(--st-running); box-shadow: var(--glow-running); }
.twin-node__badge[data-status="paused"] { background: var(--st-paused); }
.twin-node__badge[data-status="warn"] { background: var(--st-warn); box-shadow: var(--glow-warn); }
.twin-node__badge[data-status="error"] { background: var(--st-error); box-shadow: var(--glow-error); }

.twin-node__label {
  position: absolute;
  left: 50%;
  top: calc(50% + 24px);
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: var(--fs-xs);
  font-weight: 500;
  color: var(--text-dim);
  pointer-events: none;
  transition: color 170ms var(--ease-micro);
}

.twin-node:hover .twin-node__dot,
.twin-node.is-active .twin-node__dot { transform: translate(-50%, -50%) scale(1.14); }
.twin-node:hover .twin-node__halo,
.twin-node.is-active .twin-node__halo,
.twin-node.is-focus .twin-node__halo { opacity: 0.6; }
.twin-node:hover .twin-node__label,
.twin-node.is-active .twin-node__label { color: var(--text); }

.twin-node.is-focus .twin-node__dot {
  transform: translate(-50%, -50%) scale(1.2);
  box-shadow: var(--glow-accent);
}

/* 드론 부양 애니메이션 */
.twin-node--drone .twin-node__dot { animation: twin-hover 3.2s ease-in-out infinite; }
@keyframes twin-hover {
  0%, 100% { transform: translate(-50%, -50%) translateY(0); }
  50% { transform: translate(-50%, -50%) translateY(-4px); }
}

/* CCTV 스캔 인디케이터 */
.twin-node--cctv .twin-node__dot::after {
  content: "";
  position: absolute;
  inset: -7px;
  border-radius: 50%;
  border: 1px solid var(--cf-color);
  opacity: 0;
}
.twin-node--cctv.is-scanning .twin-node__dot::after {
  animation: twin-scan 1.6s var(--ease-micro) infinite;
}
@keyframes twin-scan {
  0% { transform: scale(0.7); opacity: 0.6; }
  100% { transform: scale(1.7); opacity: 0; }
}

/* 관제센터 마커 — 지도 위 랜드마크 매스 위에 얹히는 상시 거점 아이콘 */
.twin-node--hub { --cf-color: var(--accent); z-index: 1; }
.twin-node--hub .twin-node__dot { box-shadow: 0 0 0 1px var(--border), 0 0 20px rgba(var(--accent-rgb), 0.3); }
.twin-node--hub .twin-node__halo { opacity: 0.4; animation: twin-hub-idle 2.6s var(--ease-micro) infinite; }
.twin-node--hub .twin-node__label { font-weight: 700; color: var(--accent); }
@keyframes twin-hub-idle {
  0%, 100% { opacity: 0.32; }
  50% { opacity: 0.55; }
}
.twin-node--hub.is-burst .twin-node__halo { animation: twin-hub-burstpulse 900ms var(--ease-enter); }
@keyframes twin-hub-burstpulse {
  0% { opacity: 0.4; transform: translate(-50%, -50%) scale(1); }
  40% { opacity: 0.85; transform: translate(-50%, -50%) scale(1.5); }
  100% { opacity: 0.4; transform: translate(-50%, -50%) scale(1); }
}

/* 이동 중(비행·주행) — 궤적을 따라 기울어지는 잔상 링 */
.twin-node.is-moving .twin-node__dot { box-shadow: 0 0 0 1px var(--border), 0 0 20px rgba(var(--accent-rgb), 0.35); }
.twin-node.is-moving .twin-node__halo { opacity: 0.55; animation: twin-moving-halo 0.9s ease-in-out infinite; }
@keyframes twin-moving-halo {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 0.75; }
}

/* 상시 순찰 이동 — 24시간 무인 구동이라 여기에는 애니메이션을 두지 않는다.
   is-moving(시나리오 출동)이 강조를 독점해야 "지금 출동 중"이 읽힌다. 순찰은 헤일로 한 단만 올린다. */
.twin-node.is-patrolling .twin-node__halo { opacity: 0.38; }

/* 기기 등장 — reveal 시 800ms 뒤 JS가 클래스를 뗀다. 마커는 width/height 0 이라
   transform 원점이 곧 마커 중심이다(자식 전체가 중심 기준으로 커진다). */
.twin-node.is-revealing { animation: twin-reveal 640ms var(--ease-enter) both; }
@keyframes twin-reveal {
  0%   { opacity: 0; transform: scale(0.5); }
  62%  { opacity: 1; transform: scale(1.08); }
  100% { opacity: 1; transform: scale(1); }
}
.twin-node.is-revealing::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border-radius: 50%;
  border: 1.5px solid var(--cf-color);
  pointer-events: none;
  animation: twin-reveal-ring 800ms var(--ease-enter) both;
}
@keyframes twin-reveal-ring {
  0%   { transform: scale(0.4); opacity: 0.9; }
  100% { transform: scale(2.6); opacity: 0; }
}

/* 기기 퇴장 — 420ms 뒤 JS가 hidden 을 건다. both 로 사라진 상태를 유지해야 깜빡임이 없다. */
.twin-node.is-concealing { animation: twin-conceal 420ms var(--ease-micro) both; }
@keyframes twin-conceal {
  from { opacity: 1; transform: scale(1); }
  to   { opacity: 0; transform: scale(0.62); }
}

/* 작업 중(촬영·순찰 접근·방송) — 유형별 시각 표식 */
.twin-node__work {
  position: absolute;
  left: 50%;
  top: 50%;
  pointer-events: none;
}
.twin-node.is-working .twin-node__dot {
  border-color: var(--st-warn);
  box-shadow: 0 0 0 1px var(--border), var(--glow-warn);
}
.twin-node.is-working .twin-node__label { color: var(--st-warn); font-weight: 600; }

/* 기기 타입 기반 표식 — 작업 유형 클래스(.is-work-*)가 없을 때만 쓰는 폴백.
   유형이 지정되면 아래 '작업 유형별 표식'이 대신 그린다. */

/* 드론 — 회전 촬영 링 + REC 점 */
.twin-node--drone.is-working:not([class*="is-work-"]) .twin-node__work::before {
  content: "";
  position: absolute;
  left: 50%; top: 50%;
  width: 40px; height: 40px;
  margin: -20px 0 0 -20px;
  border-radius: 50%;
  border: 1.5px dashed var(--st-warn);
  opacity: 0.8;
  animation: twin-work-spin 2.6s linear infinite;
}
.twin-node--drone.is-working:not([class*="is-work-"]) .twin-node__work::after {
  content: "";
  position: absolute;
  left: 50%; top: -19px;
  width: 6px; height: 6px;
  margin-left: -3px;
  border-radius: 50%;
  background: var(--st-error);
  box-shadow: var(--glow-error);
  animation: twin-rec-blink 1s step-start infinite;
}
@keyframes twin-work-spin { to { transform: rotate(360deg); } }
@keyframes twin-rec-blink { 50% { opacity: 0.15; } }

/* 로봇 — 방송 음파 확산 링 */
.twin-node--robot.is-working:not([class*="is-work-"]) .twin-node__work::before,
.twin-node--robot.is-working:not([class*="is-work-"]) .twin-node__work::after {
  content: "";
  position: absolute;
  left: 50%; top: 50%;
  width: 18px; height: 18px;
  margin: -9px 0 0 -9px;
  border-radius: 50%;
  border: 1.5px solid var(--st-warn);
  opacity: 0;
  animation: twin-broadcast 1.6s var(--ease-micro) infinite;
}
.twin-node--robot.is-working:not([class*="is-work-"]) .twin-node__work::after { animation-delay: 0.55s; }
@keyframes twin-broadcast {
  0% { transform: scale(0.6); opacity: 0.75; }
  100% { transform: scale(2.6); opacity: 0; }
}


/* ── 작업 유형별 표식 (.is-work-* / twin.js가 .is-working 과 함께 부여) ──
   관람객이 글자를 읽지 않고도 "지금 무슨 일을 하는 중인가"를 알아야 한다.
   그래서 유형마다 그 작업의 실제 계측 형태를 빌려 쓴다:
   촬영=회전 셔터 링, 방송=음파, 열화상=열지도 프레임+스캔바,
   번호판 인식=조준 브래킷, 응급처치=적십자 심박, 사이렌=적청 교대 발광. */
.twin-node.is-working .twin-node__work { --cf-work: var(--st-warn); }
.twin-node.is-working .twin-node__work::before,
.twin-node.is-working .twin-node__work::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  border: 0;
  background: none;
  box-shadow: none;
  animation: none;
  opacity: 1;
}

/* 촬영 — 회전 셔터 링 + REC 점 */
.twin-node.is-work-record .twin-node__work::before {
  width: 44px;
  height: 44px;
  margin: -22px 0 0 -22px;
  border-radius: 50%;
  border: 1.5px dashed var(--cf-work);
  opacity: 0.8;
  animation: twin-work-spin 2.6s linear infinite;
}
.twin-node.is-work-record .twin-node__work::after {
  top: -21px;
  width: 6px;
  height: 6px;
  margin-left: -3px;
  border-radius: 50%;
  background: var(--st-error);
  box-shadow: var(--glow-error);
  animation: twin-rec-blink 1s step-start infinite;
}

/* 방송 — 음파 확산 링 2겹 */
.twin-node.is-work-broadcast .twin-node__work::before,
.twin-node.is-work-broadcast .twin-node__work::after {
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border-radius: 50%;
  border: 1.5px solid var(--cf-work);
  opacity: 0;
  animation: twin-broadcast 1.6s var(--ease-micro) infinite;
}
.twin-node.is-work-broadcast .twin-node__work::after { animation-delay: 0.55s; }

/* 열화상 — 열지도 프레임 안에서 스캔바가 위→아래로 훑는다 */
.twin-node.is-work-thermal .twin-node__work {
  width: 44px;
  height: 44px;
  margin: -22px 0 0 -22px;
  border-radius: 9px;
  border: 1.5px solid var(--cf-work);
  overflow: hidden;
}
.twin-node.is-work-thermal .twin-node__work::before {
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--st-error) 24%, transparent),
    color-mix(in srgb, var(--st-warn) 16%, transparent) 46%,
    color-mix(in srgb, var(--cat-traffic) 14%, transparent));
}
.twin-node.is-work-thermal .twin-node__work::after {
  left: 0;
  top: 0;
  width: 100%;
  height: 2px;
  background: var(--cf-work);
  box-shadow: 0 0 8px var(--cf-work);
  animation: twin-thermal-sweep 2s linear infinite;
}
@keyframes twin-thermal-sweep {
  from { transform: translateY(0); }
  to   { transform: translateY(41px); }
}

/* 번호판 인식 — 네 모서리 조준 브래킷이 반복해서 조여든다 + 인식 라인 */
.twin-node.is-work-scan .twin-node__work::before {
  width: 52px;
  height: 42px;
  margin: -21px 0 0 -26px;
  background-image:
    linear-gradient(var(--cf-work), var(--cf-work)), linear-gradient(var(--cf-work), var(--cf-work)),
    linear-gradient(var(--cf-work), var(--cf-work)), linear-gradient(var(--cf-work), var(--cf-work)),
    linear-gradient(var(--cf-work), var(--cf-work)), linear-gradient(var(--cf-work), var(--cf-work)),
    linear-gradient(var(--cf-work), var(--cf-work)), linear-gradient(var(--cf-work), var(--cf-work));
  background-size: 12px 2px, 2px 12px, 12px 2px, 2px 12px, 12px 2px, 2px 12px, 12px 2px, 2px 12px;
  background-position: left top, left top, right top, right top, left bottom, left bottom, right bottom, right bottom;
  background-repeat: no-repeat;
  animation: twin-lock 1.9s var(--ease-enter) infinite;
}
.twin-node.is-work-scan .twin-node__work::after {
  width: 44px;
  height: 2px;
  margin: -1px 0 0 -22px;
  background: var(--cf-work);
  box-shadow: 0 0 7px var(--cf-work);
  opacity: 0.85;
  animation: twin-scanline 1.9s var(--ease-micro) infinite alternate;
}
@keyframes twin-lock {
  0%       { transform: scale(1.3); opacity: 0.25; }
  42%, 100% { transform: scale(1); opacity: 0.95; }
}
@keyframes twin-scanline {
  from { transform: translateY(-18px); }
  to   { transform: translateY(18px); }
}

/* 응급처치 — 적십자 심박 (두 번 뛰고 쉰다) */
.twin-node.is-work-aid .twin-node__work { --cf-work: var(--st-error); }
.twin-node.is-work-aid .twin-node__work::before {
  width: 7px;
  height: 42px;
  margin: -21px 0 0 -3.5px;
  border-radius: 1.5px;
  background: var(--cf-work);
  animation: twin-aid-beat 1.6s var(--ease-micro) infinite;
}
.twin-node.is-work-aid .twin-node__work::after {
  width: 42px;
  height: 7px;
  margin: -3.5px 0 0 -21px;
  border-radius: 1.5px;
  background: var(--cf-work);
  box-shadow: var(--glow-error);
  animation: twin-aid-beat 1.6s var(--ease-micro) infinite;
}
@keyframes twin-aid-beat {
  0%, 26%, 100% { transform: scale(1); }
  10%           { transform: scale(1.24); }
  20%           { transform: scale(1.14); }
}

/* 사이렌 주행 — 적청 교대 발광 (경광등) */
.twin-node.is-work-siren .twin-node__work::before {
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -34px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--cat-traffic) 0%, transparent 70%);
  animation: twin-siren-l 0.9s linear infinite;
}
.twin-node.is-work-siren .twin-node__work::after {
  width: 26px;
  height: 26px;
  margin: -13px 0 0 8px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--st-error) 0%, transparent 70%);
  animation: twin-siren-r 0.9s linear infinite;
}
@keyframes twin-siren-l { 0%, 49% { opacity: 0.95; } 50%, 100% { opacity: 0.1; } }
@keyframes twin-siren-r { 0%, 49% { opacity: 0.1; } 50%, 100% { opacity: 0.95; } }

/* 응급 작업은 마커 자체도 경보색으로 — 작업색(경계 노랑)보다 앞선다 */
.twin-node.is-work-aid .twin-node__dot,
.twin-node.is-work-siren .twin-node__dot {
  border-color: var(--st-error);
  box-shadow: 0 0 0 1px var(--border), var(--glow-error);
}
.twin-node.is-work-aid .twin-node__label,
.twin-node.is-work-siren .twin-node__label { color: var(--st-error); }

/* ── 감지 순간의 경계 스캔 (.is-scanning-alert) ─────────────
   CCTV 상시 스캔(.is-scanning)은 한 겹·저채도, 이건 두 겹·경보색으로 세기를 올린다. */
.twin-node.is-scanning-alert::before,
.twin-node.is-scanning-alert::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border-radius: 50%;
  border: 1.5px solid var(--st-error);
  pointer-events: none;
  animation: twin-alertscan 1.5s var(--ease-micro) infinite;
}
.twin-node.is-scanning-alert::after { animation-delay: 0.5s; }
@keyframes twin-alertscan {
  0%   { transform: scale(0.5); opacity: 0.85; }
  100% { transform: scale(2.4); opacity: 0; }
}
.twin-node.is-scanning-alert .twin-node__dot {
  border-color: var(--st-error);
  box-shadow: 0 0 0 1px var(--border), var(--glow-error);
}
.twin-node.is-scanning-alert .twin-node__halo {
  opacity: 0.6;
  background: radial-gradient(circle, var(--st-error) 0%, transparent 68%);
}

/* ── 순찰 복귀 (.is-returning) — 물러나는 상태라 링이 바깥에서 안으로 수렴한다 ── */
.twin-node.is-returning .twin-node__dot {
  border-color: var(--st-paused);
  border-color: color-mix(in srgb, var(--cf-color) 40%, var(--text-faint));
  box-shadow: 0 0 0 1px var(--border);
}
.twin-node.is-returning .twin-node__label { color: var(--text-faint); font-weight: 400; }
.twin-node.is-returning .twin-node__halo { opacity: 0.14; }
.twin-node.is-returning::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border-radius: 50%;
  border: 1px solid var(--st-paused);
  pointer-events: none;
  animation: twin-return-in 2.6s var(--ease-enter) infinite;
}
@keyframes twin-return-in {
  0%   { transform: scale(1.9); opacity: 0; }
  35%  { opacity: 0.45; }
  100% { transform: scale(0.85); opacity: 0; }
}

/* ── 신규 기기 타입 ─────────────────────────────────────────
   원형=이동체/센서, 각진 모서리=고정 구조물. 멀리서도 성격이 갈리게. */

/* 스마트 쉘터 — 고정 구조물, 상시 조용함 */
.twin-node--shelter .twin-node__dot { border-radius: 9px; }
.twin-node--shelter .twin-node__halo { opacity: 0.18; }

/* 119 안전센터 — 출동 거점: 크고 두꺼운 마커 + 상시 대기 링 */
.twin-node--station119 .twin-node__dot {
  width: 34px;
  height: 34px;
  border-radius: 10px;
  border-width: 2px;
}
.twin-node--station119 .twin-node__dot::after {
  content: "";
  position: absolute;
  inset: -6px;
  border-radius: 13px;
  border: 1px solid var(--st-error);
  border-color: color-mix(in srgb, var(--st-error) 42%, transparent);
}
.twin-node--station119 .twin-node__label { font-weight: 700; }

/* 구급차 — 이동체: 접지 그림자를 키우고 긴급 계열 톤을 얹는다 */
.twin-node--ambulance .twin-node__dot { border-radius: 8px; border-color: var(--st-error); }
.twin-node--ambulance .twin-node__shadow { width: 30px; height: 10px; }
.twin-node--ambulance .twin-node__halo {
  background: radial-gradient(circle, var(--st-error) 0%, transparent 68%);
  opacity: 0.24;
}

/* 위반 차량 — 확정 전 '관측 대상'이라 파선 테두리로 미확정을 표시한다 */
.twin-node--vehicle .twin-node__dot { border-style: dashed; }
.twin-node--vehicle .twin-node__halo { opacity: 0.16; }
.twin-node--vehicle .twin-node__label { color: var(--text-faint); }

/* 병원 — 원거리 고정 랜드마크: 발광 없이 이중 링만 두른 차분한 톤 */
.twin-node--hospital .twin-node__dot {
  border-radius: 9px;
  box-shadow: 0 0 0 1px var(--border);
}
.twin-node--hospital .twin-node__dot::after {
  content: "";
  position: absolute;
  inset: -5px;
  border-radius: 12px;
  border: 1px solid var(--border-strong);
}
.twin-node--hospital .twin-node__halo { opacity: 0.12; }
.twin-node--hospital .twin-node__label { font-weight: 600; color: var(--text-dim); }

/* ── 이벤트 마커 (DetectedEvent) ───────────────────────────── */
.twin-event {
  position: absolute;
  z-index: 5;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--t-enter);
}
.twin-event.is-on { opacity: 1; }
.twin-event__ring {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 20px;
  height: 20px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  border: 1.5px solid var(--st-error);
  animation: twin-event-pulse 1.8s var(--ease-micro) infinite;
}
.twin-event__ring--2 { animation-delay: 0.6s; }
.twin-event__core {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 14px;
  height: 14px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--st-error);
  box-shadow: var(--glow-error);
  display: grid;
  place-items: center;
}
.twin-event__core svg { width: 9px; height: 9px; stroke: var(--text-on-accent); stroke-width: 2; }

@keyframes twin-event-pulse {
  0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.7; }
  100% { transform: translate(-50%, -50%) scale(2.4); opacity: 0; }
}

/* ── 상황 말풍선(콜아웃) ───────────────────────────────────── */
.twin-callout {
  position: absolute;
  z-index: 16;
  transform: translate(-50%, -100%);
  pointer-events: none;
  --cf-tone: var(--st-error);
}
.twin-callout[hidden] { display: none; }
.twin-callout__bubble {
  position: relative;
  min-width: 170px;
  max-width: 240px;
  padding: 10px 13px;
  background: var(--surface-glass-strong);
  border: 1.5px solid var(--cf-tone);
  border-radius: 13px;
  backdrop-filter: blur(14px);
  box-shadow: 0 0 0 1px var(--border), 0 0 22px rgba(var(--accent-rgb), 0.12);
  animation: twin-callout-breathe 2.4s ease-in-out infinite;
}
.twin-callout__bubble::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -7px;
  width: 13px;
  height: 13px;
  margin-left: -6.5px;
  background: var(--surface-glass-strong);
  border-right: 1.5px solid var(--cf-tone);
  border-bottom: 1.5px solid var(--cf-tone);
  transform: rotate(45deg);
}
.twin-callout__head { display: flex; align-items: center; gap: 7px; }
.twin-callout__icon {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--cf-tone);
  color: var(--text-on-accent);
  font-size: 11px;
  font-weight: 800;
}
.twin-callout__title { font-size: var(--fs-sm); font-weight: 700; letter-spacing: -0.01em; }
.twin-callout__sub { margin: 4px 0 0; font-size: var(--fs-xs); color: var(--text-dim); }
.twin-callout.is-pop { animation: twin-callout-pop 520ms var(--ease-enter) both; }
@keyframes twin-callout-pop {
  0% { opacity: 0; transform: translate(-50%, -78%) scale(0.35); }
  62% { opacity: 1; transform: translate(-50%, -106%) scale(1.05); }
  100% { opacity: 1; transform: translate(-50%, -100%) scale(1); }
}
/* 단계 전환 — 이미 떠 있는 말풍선을 접었다가 다음 내용으로 편다.
   JS가 170ms 뒤 내용을 갈아끼우므로 접힌 상태를 both 로 유지해야 교체 순간이 보이지 않는다. */
.twin-callout.is-swap { animation: twin-callout-swap 170ms var(--ease-micro) both; }
@keyframes twin-callout-swap {
  from { opacity: 1; transform: translate(-50%, -100%) scale(1); }
  to   { opacity: 0; transform: translate(-50%, -92%) scale(0.86); }
}

@keyframes twin-callout-breathe {
  0%, 100% { box-shadow: 0 0 0 1px var(--border), 0 0 16px color-mix(in srgb, var(--cf-tone) 12%, transparent); }
  50%      { box-shadow: 0 0 0 1px var(--border), 0 0 30px color-mix(in srgb, var(--cf-tone) 28%, transparent); }
}

/* ── 상황 단계 톤 (SC-10) ───────────────────────────────────
   query는 아직 좋고 나쁨이 정해지지 않은 '인지' 단계라 상태 4색을 피해 시설색을 쓴다.
   process는 시스템이 연산 중이므로 시스템 주색(액센트), info는 사실 전달(교통색),
   warn·danger·success는 상태색 그대로. 색이 겹치는 테마에서도 아이콘 거동
   (스피너·소나·점멸)이 달라 단계는 구분된다. */
.twin-callout--query   { --cf-tone: var(--cat-facility); }
.twin-callout--process { --cf-tone: var(--accent); }
.twin-callout--info    { --cf-tone: var(--cat-traffic); }
.twin-callout--warn    { --cf-tone: var(--st-warn); }
.twin-callout--danger  { --cf-tone: var(--st-error); }
.twin-callout--success { --cf-tone: var(--st-running); }

/* 인지(물음표) — 아이콘에서 소나가 퍼진다: 무언가를 찾는 중 */
.twin-callout--query .twin-callout__icon::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1.5px solid var(--cf-tone);
  animation: twin-callout-sonar 1.9s var(--ease-micro) infinite;
}
@keyframes twin-callout-sonar {
  0%   { transform: scale(1); opacity: 0.7; }
  100% { transform: scale(2.2); opacity: 0; }
}
.twin-callout--query .twin-callout__bubble { animation-duration: 2.6s; }

/* 처리중 — 아이콘 문자가 비어 있어도(icon:'') 성립해야 한다.
   그래서 링 스피너와 하단 진행바를 CSS만으로 만든다. */
.twin-callout--process .twin-callout__icon {
  background: color-mix(in srgb, var(--cf-tone) 16%, transparent);
  color: var(--cf-tone);
  font-size: 9px;
  letter-spacing: 0.02em;
}
.twin-callout--process .twin-callout__icon::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  border: 1.5px solid var(--cf-tone);
  border-color: color-mix(in srgb, var(--cf-tone) 20%, transparent);
  border-top-color: var(--cf-tone);
  animation: twin-callout-spin 820ms linear infinite;
}
@keyframes twin-callout-spin { to { transform: rotate(360deg); } }
.twin-callout--process .twin-callout__bubble {
  padding-bottom: 16px;
  animation-duration: 3.4s;
}
/* 진행바는 width가 아니라 scaleX로 왕복시킨다 — 레이아웃을 건드리지 않는다 */
.twin-callout--process .twin-callout__bubble::before {
  content: "";
  position: absolute;
  left: 13px;
  right: 13px;
  bottom: 7px;
  height: 2px;
  border-radius: 2px;
  background: var(--cf-tone);
  opacity: 0.8;
  animation: twin-callout-progress 1.7s var(--ease-micro) infinite;
}
@keyframes twin-callout-progress {
  0%   { transform: scaleX(0); transform-origin: left; }
  45%  { transform: scaleX(1); transform-origin: left; }
  55%  { transform: scaleX(1); transform-origin: right; }
  100% { transform: scaleX(0); transform-origin: right; }
}

/* 경보 — 등장은 짧고 날카롭게, 이후 테두리가 규칙적으로 달아오른다 */
.twin-callout--danger.is-pop { animation-duration: 340ms; }
.twin-callout--danger .twin-callout__icon { animation: twin-hub-pulse-solid 1s ease-in-out infinite; }
.twin-callout--danger .twin-callout__bubble { animation: twin-callout-alarm 1s ease-in-out infinite; }
@keyframes twin-callout-alarm {
  0%, 100% { box-shadow: 0 0 0 1px var(--border), 0 0 14px color-mix(in srgb, var(--cf-tone) 14%, transparent); }
  45%      { box-shadow: 0 0 0 2px color-mix(in srgb, var(--cf-tone) 55%, transparent), 0 0 34px color-mix(in srgb, var(--cf-tone) 42%, transparent); }
}

/* 경계 — 경보보다 한 박자 느린 맥동 */
.twin-callout--warn .twin-callout__bubble { animation-duration: 1.8s; }

/* 확정·완료 — 링이 한 번만 퍼지고 끝난다(반복 없음) */
.twin-callout--success .twin-callout__icon::after,
.twin-callout--info .twin-callout__icon::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1.5px solid var(--cf-tone);
  animation: twin-callout-sonar 900ms var(--ease-micro) 1 both;
}
.twin-callout--success .twin-callout__bubble { animation-duration: 3s; }

[data-theme="light"] .twin-callout__bubble { box-shadow: 0 2px 14px var(--overlay-scrim); }

/* ── 팝오버 ────────────────────────────────────────────────── */
.twin-pop {
  position: absolute;
  z-index: 20;
  width: 250px;
  padding: var(--sp-4);
  background: var(--surface-glass-strong);
  border: 1px solid var(--border-strong);
  border-left: 2px solid var(--cf-color, var(--accent));
  border-radius: var(--radius);
  backdrop-filter: blur(14px);
  animation: twin-pop-in var(--dur-micro) var(--ease-enter) both;
}
.twin-pop[hidden] { display: none; }
/* 꼬리 — 카드가 항상 대상 위에 고정되므로(js/twin.js positionPop) 어느 기기의 정보인지
   가리키는 표시가 있어야 한다. 화면 가장자리에서 카드가 안쪽으로 밀리면 JS 가 --cf-caret 로
   꼬리만 따로 옮겨 계속 대상을 겨눈다. */
.twin-pop::after {
  content: "";
  position: absolute;
  left: var(--cf-caret, 50%);
  top: 100%;
  width: 11px;
  height: 11px;
  transform: translate(-50%, -58%) rotate(45deg);
  background: var(--surface-glass-strong);
  border-right: 1px solid var(--border-strong);
  border-bottom: 1px solid var(--border-strong);
  pointer-events: none;
}
@keyframes twin-pop-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
.twin-pop__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  margin-bottom: 4px;
}
.twin-pop__role {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--cf-color, var(--text-dim));
}
.twin-pop__title { font-size: var(--fs-h3); font-weight: 600; letter-spacing: -0.01em; margin-top: 2px; }
.twin-pop__desc { margin-top: 3px; font-size: var(--fs-xs); color: var(--text-dim); }
.twin-pop__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  margin-top: var(--sp-3);
  padding-top: var(--sp-3);
  border-top: 1px solid var(--border);
}
.twin-pop__meta dt { font-size: var(--fs-xs); color: var(--text-faint); }
.twin-pop__meta dd { margin: 1px 0 0; font-size: var(--fs-sm); font-weight: 500; }

/* ── HUD ───────────────────────────────────────────────────── */
.twin-hud { position: absolute; z-index: 10; pointer-events: none; }
/* 좌측 상단 블록은 배경판 없이 지도 위에 바로 얹힌다. 위성영상은 밝은 지붕과 어두운 그림자가
   한 화면에 섞여 있어 회색·흰색 글자가 구간마다 사라진다(CEO 지적).
   8방향 1px 검정 외곽 + 넓은 검정 후광으로 어떤 배경 위에서도 글자가 떠 보이게 한다.
   배경판을 깔지 않는 이유는 지도를 가리지 않기 위해서다. */
.twin-hud--tl {
  inset-inline-start: var(--sp-5);
  top: var(--sp-5);
  text-shadow:
     1px  1px 0 rgba(0, 0, 0, 0.82),
    -1px  1px 0 rgba(0, 0, 0, 0.82),
     1px -1px 0 rgba(0, 0, 0, 0.82),
    -1px -1px 0 rgba(0, 0, 0, 0.82),
     0    0   9px rgba(0, 0, 0, 0.75);
}
.twin-hud--tr { inset-inline-end: var(--sp-5); top: var(--sp-5); pointer-events: auto; }
.twin-hud--tr-stack { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-2); }
.twin-graphlink { display: inline-flex; align-items: center; gap: 6px; }
.twin-graphlink svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.twin-hud--br { inset-inline-end: var(--sp-5); bottom: var(--sp-5); text-align: end; }

.twin-hud__title { margin-top: 2px; font-size: var(--fs-h1); font-weight: 600; letter-spacing: -0.025em; }
.twin-hud__sub { margin-top: 2px; font-size: var(--fs-sm); color: var(--text-dim); }
.twin-hud__stat {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 10px;
  font-size: var(--fs-xs);
  font-family: var(--font-mono);
  color: var(--text-faint);
}
.twin-hud__stat b { color: var(--text-dim); font-weight: 600; }
.twin-hud__stat-dot {
  width: 5px; height: 5px; border-radius: 50%;
  background: var(--st-running); box-shadow: var(--glow-running);
  animation: twin-hub-pulse-solid 1.6s ease-in-out infinite;
}
@keyframes twin-hub-pulse-solid { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

.twin-legend {
  display: grid;
  gap: 5px;
  padding: var(--sp-3) var(--sp-4);
  background: var(--surface-glass);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  backdrop-filter: blur(10px);
  min-width: 168px;
}
.twin-legend__row {
  display: grid;
  grid-template-columns: 9px 1fr auto;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--fs-sm);
  color: var(--text-dim);
}
.twin-legend__row .cf-dot { width: 8px; height: 8px; }
.twin-legend__n { font-size: var(--fs-xs); color: var(--text-faint); font-family: var(--font-mono); }

/* ── 흐름 범례 (SC-9) — 기기 상태 범례와 같은 유리 패널 언어 ──
   스와치는 지도 위 궤적을 그대로 축소한 것이다: 유형별 대시 리듬 + 진행 끝의 패킷.
   8종을 세로로 늘어놓으면 패널이 너무 길어져 2열로 접는다. */
.twin-flowlegend {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 4px var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  min-width: 168px;
  background: var(--surface-glass);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  backdrop-filter: blur(10px);
}
.twin-flowlegend:empty { display: none; }
.twin-flowlegend[hidden] { display: none; }
.twin-flowlegend__title {
  grid-column: 1 / -1;
  margin-bottom: 1px;
  font-size: var(--fs-xs);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.twin-flowlegend__item {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  color: var(--text-dim);
  white-space: nowrap;
}
/* 스와치는 지도 위 궤적을 그대로 축소한 것이다 — 막대 두께는 실물 stroke-width(--tfx-w)를,
   끝의 헤드는 패킷 형태를 그대로 쓴다. 값을 따로 들고 있으면 범례가 거짓말을 하게 된다. */
.twin-flowlegend__dot {
  position: relative;
  flex: none;
  width: 22px;
  height: 12px;
}
.twin-flowlegend__dot::before {
  content: "";
  position: absolute;
  left: 0;
  right: 8px;
  top: 50%;
  height: max(1.5px, var(--tfx-w, 2.4px));
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--tfx-c, var(--text-faint)) var(--tfx-a, 82%), transparent);
  transform: translateY(-50%);
}
.twin-flowlegend__dot::after {
  content: "";
  position: absolute;
  right: 0;
  top: 50%;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--tfx-c, var(--text-faint));
  transform: translateY(-50%);
}
/* 타진·응답은 지도 위에서 속이 빈 링이라 범례 헤드도 링으로 뚫는다 */
.twin-flowlegend__item[data-kind="probe"] .twin-flowlegend__dot::after {
  width: 9px;
  height: 9px;
  background: none;
  border: 1.6px solid var(--tfx-c);
}
.twin-flowlegend__item[data-kind="ack"] .twin-flowlegend__dot::after {
  border: 1.4px solid var(--tfx-c);
  background: radial-gradient(circle at 50% 50%, var(--tfx-c) 0 28%, transparent 34%);
}
/* 확정은 림까지, 해제는 저채도 작은 점 — 지도에서와 같은 극단 대비를 범례에서도 유지한다 */
.twin-flowlegend__item[data-kind="assign"] .twin-flowlegend__dot::after {
  width: 10px;
  height: 10px;
  box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--tfx-c) 34%, transparent);
}
.twin-flowlegend__item[data-kind="assign"] .twin-flowlegend__dot::before { right: 11px; }
.twin-flowlegend__item[data-kind="release"] .twin-flowlegend__dot::after {
  width: 5px;
  height: 5px;
  background: color-mix(in srgb, var(--tfx-c) 55%, transparent);
}
/* 보고는 데이터 블록(둥근 사각), 인계는 캡슐 */
.twin-flowlegend__item[data-kind="report"] .twin-flowlegend__dot::after { border-radius: 2px; }
.twin-flowlegend__item[data-kind="handover"] .twin-flowlegend__dot::after {
  width: 13px;
  height: 7px;
  border-radius: var(--radius-pill);
}
.twin-flowlegend__item[data-kind="handover"] .twin-flowlegend__dot::before { right: 14px; }
.twin-flowlegend__label { white-space: nowrap; }

.twin-clock {
  display: grid;
  gap: 1px;
  padding: var(--sp-3) var(--sp-4);
  background: var(--surface-glass);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  backdrop-filter: blur(10px);
}
.twin-clock__time { font-family: var(--font-mono); font-size: var(--fs-h3); font-weight: 500; letter-spacing: -0.01em; }
.twin-clock__sw { font-size: var(--fs-xs); color: var(--text-faint); }

/* ── 특이사항 알림 배너(한/영) ─────────────────────────────── */
.twin-alert {
  position: absolute;
  left: 50%;
  bottom: calc(var(--sp-5) + var(--twin-bottom-lift));
  z-index: 15;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  max-width: min(560px, calc(100% - var(--sp-7) * 2));
  padding: var(--sp-3) var(--sp-4);
  background: var(--surface-glass-strong);
  border: 1px solid var(--border-strong);
  border-inline-start: 3px solid var(--st-error);
  border-radius: var(--radius);
  backdrop-filter: blur(14px);
  transform: translate(-50%, 0);
  animation: twin-alert-in var(--dur-enter) var(--ease-enter) both;
}
.twin-alert[hidden] { display: none; }
@keyframes twin-alert-in {
  from { opacity: 0; transform: translate(-50%, 10px); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}
.twin-alert__dot {
  flex: none;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--st-error);
  box-shadow: var(--glow-error);
}
.twin-alert__ko { font-size: var(--fs-sm); font-weight: 600; }
.twin-alert__en { font-size: var(--fs-xs); color: var(--text-dim); }

/* ── 카메라 시점 제어 (SC-20 · 계약서 §5-3) ──────────────────
   정보 패널(범례·시계·도크)은 사각형이고, 관람객이 누르는 컨트롤은 알약이다.
   같은 유리 재질을 쓰되 형태로 "읽는 것"과 "누르는 것"을 가른다.

   상태 3단은 색으로 말한다 — 출동 전(비활성 회색) → 출동 중(경고색 테두리·상태문구)
   → 추적 중(액센트로 채워짐 + 상태문구 앞 맥동 점).
   중간 단계에 점멸을 걸지 않은 이유: 24시간 무인 구동에서 상시 점멸은 소음이 되고,
   정작 중요한 '추적 중'의 맥동과 구분이 사라진다.

   컨테이너는 pointer-events:none 이다 — 중앙 하단 컨테이너가 화면 폭을 가로지르므로
   그대로 두면 지도 조작이 막힌다. 버튼만 다시 auto 로 되돌린다. */
.twin-camctl {
  position: absolute;
  z-index: 14;
  display: flex;
  gap: var(--sp-2);
  pointer-events: none;
  animation: twin-camctl-in var(--dur-enter) var(--ease-enter);
}
.twin-camctl[hidden] { display: none; }
@keyframes twin-camctl-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}
/* 중앙 정렬에 transform 을 쓰지 않는다 — 등장 애니메이션의 transform 과 충돌한다 */
.twin-camctl--bc {
  left: 0;
  right: 0;
  bottom: var(--sp-5);
  justify-content: center;
}
.twin-camctl--bl {
  inset-inline-start: var(--sp-5);
  bottom: var(--sp-5);
}

.twin-cambtn {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  min-height: var(--twin-cambtn-h);
  padding-block: 0;
  padding-inline: var(--sp-3) var(--sp-4);
  pointer-events: auto;
  color: var(--text-dim);
  background: var(--surface-glass-strong);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-pill);
  backdrop-filter: blur(14px);
  transition:
    color var(--t-micro),
    opacity var(--t-micro),
    background var(--t-micro),
    border-color var(--t-micro),
    box-shadow var(--t-micro),
    transform var(--t-micro);
}
/* twin.html 의 아이콘 path 에는 stroke 속성이 없다 — 리셋의 svg{fill:none} 만으로는
   선이 그려지지 않으므로 획 속성을 여기서 전부 준다 */
.twin-cambtn__icon {
  flex: none;
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.twin-cambtn__label {
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
  /* 폭 상한(R10) — 영어 «Awaiting dispatch» 17자에서 하단 중앙 버튼을 3px 침범했던 자리. 아랍어는 영어보다 +15% 길다.
     문안 단축으로 덮은 임시 조치 대신 상한을 두고 말줄임한다(다국어-2차확장-결정사항 결정 2). */
  max-width: 168px;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color var(--t-micro);
}
/* 기기 이름과 계기 읽음값을 하이라인으로 가른다 */
.twin-cambtn__state {
  position: relative;
  padding-inline-start: var(--sp-3);
  font-size: var(--fs-xs);
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--text-faint);
  white-space: nowrap;
  max-width: 140px;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color var(--t-micro), padding-inline-start var(--t-micro);
}
.twin-cambtn__state::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  top: 50%;
  width: 1px;
  height: 12px;
  background: var(--border-strong);
  transform: translateY(-50%);
}

.twin-cambtn:not([disabled]):hover {
  opacity: 1;
  border-color: var(--border-accent);
  box-shadow: var(--glow-accent-soft);
  transform: translateY(-1px);
}
.twin-cambtn:not([disabled]):active { transform: translateY(0); }
.twin-cambtn[disabled] {
  cursor: default;
  opacity: 0.5;
  border-color: var(--border);
}
.twin-cambtn[disabled] .twin-cambtn__label { color: var(--text-dim); font-weight: 500; }

/* 출동 중 — 아직 추적은 안 걸렸지만 이 버튼에 의미가 생겼다는 신호.
   파견 상황을 경고색으로 물들여 상태문구 자체가 계기판 역할을 한다.
   :not([aria-pressed="true"]) 를 함께 걸어 두는 이유: 추적 규칙보다 특이도가 높아
   클래스가 늦게 붙는 순간 경고색이 액센트를 덮는 사고를 막는다. */
.twin-cambtn--follow:not([disabled]):not(.is-active):not([aria-pressed="true"]) {
  border-color: color-mix(in srgb, var(--st-warn) 55%, var(--border-strong));
}
.twin-cambtn--follow:not([disabled]):not(.is-active):not([aria-pressed="true"]) .twin-cambtn__icon,
.twin-cambtn--follow:not([disabled]):not(.is-active):not([aria-pressed="true"]) .twin-cambtn__state {
  color: var(--st-warn);
}

/* 추적 중 — 시점이 이 기기에 잠겼다. 채워진 알약 + 상태문구 앞 맥동 점 하나.
   맥동은 추적이 걸린 동안에만 돈다(무한 애니메이션 1개). */
.twin-cambtn.is-active,
.twin-cambtn[aria-pressed="true"] {
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, var(--surface-glass-strong));
  border-color: var(--accent);
  box-shadow: var(--glow-accent-soft);
}
.twin-cambtn.is-active .twin-cambtn__label,
.twin-cambtn[aria-pressed="true"] .twin-cambtn__label { color: var(--accent); }
.twin-cambtn.is-active .twin-cambtn__state,
.twin-cambtn[aria-pressed="true"] .twin-cambtn__state {
  padding-left: calc(var(--sp-3) + 11px);
  color: var(--accent);
}
.twin-cambtn.is-active .twin-cambtn__state::after,
.twin-cambtn[aria-pressed="true"] .twin-cambtn__state::after {
  content: "";
  position: absolute;
  left: var(--sp-3);
  top: 50%;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  transform: translateY(-50%);
  animation: twin-hub-pulse-solid 1.6s ease-in-out infinite;
}

/* 시청 복귀 — 시점이 시청에 있을 때 복귀 버튼은 할 일이 없다. 조용히 물러나 있다가
   벗어난 순간(.is-away) 테두리·아이콘이 액센트로 올라선다.
   점멸이 아니라 2단 대비로 처리해야 24시간 켜져 있어도 거슬리지 않는다. */
.twin-cambtn--home:not(.is-away) { opacity: 0.72; }
.twin-cambtn--home:not(.is-away) .twin-cambtn__label { color: var(--text-dim); font-weight: 500; }
.twin-cambtn--home.is-away {
  border-color: var(--border-accent);
  box-shadow: var(--glow-accent-soft);
}
.twin-cambtn--home.is-away .twin-cambtn__icon { color: var(--accent); }

/* ── 시나리오 도크(좌하단, 접이식) ──────────────────────────── */
.twin-dock {
  position: absolute;
  inset-inline-start: var(--sp-5);
  bottom: calc(var(--sp-5) + var(--twin-bottom-lift));
  z-index: 12;
  width: 336px;
  max-width: calc(100% - var(--sp-5) * 2);
  background: var(--surface-glass-strong);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  backdrop-filter: blur(14px);
  /* 시나리오 생성 메뉴가 도크 위쪽으로 열린다 — overflow:hidden 이면 잘린다.
     둥근 모서리 클리핑은 각 내부 영역(.twin-dock__body)이 따로 맡는다. */
  overflow: visible;
  pointer-events: auto;
}
.twin-dock__head {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-3) var(--sp-4) 2px;
  background: none;
  border: 0;
  cursor: pointer;
  text-align: left;
  color: inherit;
}
.twin-dock__chev {
  flex: none;
  width: 16px;
  height: 16px;
  color: var(--text-faint);
  stroke: currentColor;
  stroke-width: 1.8;
  transition: transform var(--t-micro);
}
.twin-dock[data-open="false"] .twin-dock__chev { transform: rotate(-90deg); }
.twin-dock__titles { flex: 1 1 auto; min-width: 0; }
.twin-auto-dot {
  display: inline-block;
  width: 6px; height: 6px;
  margin-right: 6px;
  border-radius: 50%;
  background: var(--st-running);
  box-shadow: var(--glow-running);
  vertical-align: middle;
  animation: twin-hub-pulse-solid 1.6s ease-in-out infinite;
}
.twin-auto-dot.is-paused { background: var(--text-faint); box-shadow: none; animation: none; }
/* ── 시나리오 생성 행 (SC-8) ────────────────────────────────
   도크가 좌하단이라 메뉴는 반드시 위로 열린다(bottom: 100%). */
.twin-dock__genrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: 6px var(--sp-4) var(--sp-3);
}
.twin-gen { position: relative; flex: none; }
.twin-gen__btn { gap: 6px; padding: 0 10px; }
.twin-gen__btn .twin-gen__plus { width: 14px; height: 14px; stroke-width: 2; }
.twin-gen__btn .twin-gen__chev {
  width: 13px;
  height: 13px;
  stroke-width: 2;
  opacity: 0.8;
  transition: transform var(--t-micro);
}
.twin-gen__btn[aria-expanded="true"],
.twin-gen__btn.is-open { box-shadow: var(--glow-accent); }
.twin-gen__btn[aria-expanded="true"] .twin-gen__chev,
.twin-gen__btn.is-open .twin-gen__chev { transform: rotate(180deg); }

.twin-gen__menu {
  position: absolute;
  left: 0;
  bottom: calc(100% + 8px);
  z-index: 30;
  width: 292px;
  max-width: 72vw;
  max-height: 316px;
  overflow-y: auto;
  padding: 5px;
  background: var(--surface-glass-strong);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  backdrop-filter: blur(16px);
  box-shadow: 0 -12px 34px var(--overlay-scrim);
  animation: twin-gen-rise 180ms var(--ease-enter) both;
}
.twin-gen__menu[hidden] { display: none; }
@keyframes twin-gen-rise {
  from { opacity: 0; transform: translateY(8px) scale(0.98); }
  to   { opacity: 1; transform: none; }
}

.twin-gen__item {
  display: block;
  width: 100%;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  text-align: left;
  color: inherit;
  cursor: pointer;
  transition: background var(--t-micro), box-shadow var(--t-micro);
}
.twin-gen__item + .twin-gen__item { margin-top: 2px; }
.twin-gen__item:hover,
.twin-gen__item:focus-visible,
.twin-gen__item.is-active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
.twin-gen__item[disabled],
.twin-gen__item[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
.twin-gen__item-title { display: block; font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em; }
.twin-gen__item-sub {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  margin-top: 2px;
  font-size: var(--fs-xs);
  line-height: 1.45;
  color: var(--text-dim);
}
.twin-gen__item-loc {
  display: block;
  margin-top: 3px;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--text-faint);
}
.twin-gen__item-loc:empty { display: none; }
.twin-gen__item.is-queued .twin-gen__item-loc,
.twin-gen__item.is-running .twin-gen__item-loc { color: var(--accent); }

/* ── 시나리오 대기열 칩 (SC-8) ──────────────────────────────── */
.twin-queue {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  padding: 0 var(--sp-4) var(--sp-3);
}
.twin-queue[hidden] { display: none; }
.twin-queue__chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 5px 3px 8px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  font-size: var(--fs-xs);
  color: var(--text-dim);
  transition: color var(--t-micro), background var(--t-micro), border-color var(--t-micro);
}
.twin-queue__n {
  display: grid;
  place-items: center;
  flex: none;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text-faint);
}
.twin-queue__label {
  max-width: 104px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.twin-queue__cancel {
  display: grid;
  place-items: center;
  flex: none;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  color: var(--text-faint);
  line-height: 1;
  cursor: pointer;
  transition: color var(--t-micro), background var(--t-micro);
}
.twin-queue__cancel:hover { color: var(--text); background: var(--border-strong); }
.twin-queue__cancel svg { width: 9px; height: 9px; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }

/* 진행 중 칩은 취소 버튼 자리에 회전 인디케이터가 들어선다 —
   번호 배지까지 맥동시키면 한 칩에 모션이 둘이라 시끄럽다. 번호는 정적 액센트로 두고 모션은 여기만 맡는다. */
.twin-queue__progress {
  flex: none;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 1.5px solid var(--border-strong);
  border-top-color: var(--accent);
  animation: twin-queue-spin 900ms linear infinite;
}
@keyframes twin-queue-spin { to { transform: rotate(360deg); } }

/* 선두(진행 중) 칩만 액센트로 선다 */
.twin-queue__chip.is-running {
  color: var(--text);
  font-weight: 600;
  background: var(--accent-soft);
  border-color: var(--border-accent);
}
.twin-queue__chip.is-running .twin-queue__n {
  background: var(--accent);
  color: var(--text-on-accent);
}
.twin-dock__body {
  max-height: 320px;
  overflow: hidden;
  transition: max-height var(--dur-enter) var(--ease-enter);
  padding: 0 var(--sp-4) var(--sp-4);
}
.twin-dock[data-open="false"] .twin-dock__body { max-height: 0; padding-top: 0; padding-bottom: 0; }

/* ── 우측 패널 ─────────────────────────────────────────────── */
.twin-aside {
  flex: none;
  width: 348px;
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--sp-5);
  overflow-y: auto;
  border-left: 1px solid var(--border);
  background: var(--surface);
}

.twin-panel { display: flex; flex-direction: column; flex: none; min-height: 0; }
.twin-panel--fill { flex: 1 1 auto; min-height: 200px; }

.twin-panel__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-2);
  margin-bottom: var(--sp-3);
}
.twin-panel__title { margin-top: 2px; font-size: var(--fs-h3); font-weight: 600; letter-spacing: -0.01em; }
.twin-panel__meta { font-size: var(--fs-xs); color: var(--text-faint); font-family: var(--font-mono); white-space: nowrap; }
.twin-panel__loc { margin: -6px 0 var(--sp-3); font-size: var(--fs-xs); color: var(--text-dim); }

/* 접이식 패널(기기 상태) — 로그 영역에 세로 공간을 양보 */
.twin-panel__toggle {
  width: 100%;
  padding: 0;
  background: none;
  border: 0;
  cursor: pointer;
  color: inherit;
  text-align: left;
  align-items: center;
}
.twin-panel__toggle-titles { flex: 1 1 auto; display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); }
.twin-chev {
  flex: none;
  width: 14px;
  height: 14px;
  color: var(--text-faint);
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform var(--t-micro);
}
.twin-panel[data-open="false"] .twin-chev { transform: rotate(-90deg); }
.twin-panel[data-open="false"] .twin-panel__head { margin-bottom: 0; }
.twin-collapse { max-height: 260px; overflow: hidden; transition: max-height var(--dur-enter) var(--ease-enter); }
.twin-panel[data-open="false"] .twin-collapse { max-height: 0; }

/* 기기 상태 리스트 */
.twin-devicelist { display: grid; gap: 2px; max-height: 232px; overflow-y: auto; }
.twin-devicerow {
  display: grid;
  grid-template-columns: 22px 1fr auto;
  align-items: center;
  gap: var(--sp-2);
  padding: 7px var(--sp-2);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background var(--t-micro);
}
.twin-devicerow:hover,
.twin-devicerow.is-active { background: var(--surface-2); }
.twin-devicerow__icon {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  color: var(--text-dim);
}
.twin-devicerow__icon svg { width: 15px; height: 15px; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.twin-devicerow__name { font-size: var(--fs-sm); font-weight: 500; }
.twin-devicerow__role { font-size: var(--fs-xs); color: var(--text-faint); }
.twin-devicerow__batt { font-size: var(--fs-xs); font-family: var(--font-mono); color: var(--text-dim); text-align: right; }

/* 시나리오 단계 리스트 */
.twin-steps { display: grid; gap: 1px; max-height: 268px; overflow-y: auto; }
.twin-step {
  display: grid;
  grid-template-columns: 20px 1fr;
  gap: var(--sp-2);
  padding: 6px var(--sp-2);
  border-radius: var(--radius-sm);
  font-size: var(--fs-xs);
  color: var(--text-faint);
  transition: color var(--t-micro), background var(--t-micro);
}
.twin-step__n {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 1px solid var(--border-strong);
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-faint);
}
.twin-step.is-done { color: var(--text-dim); }
.twin-step.is-done .twin-step__n { border-color: var(--st-running); color: var(--st-running); }
.twin-step.is-active {
  color: var(--text);
  background: var(--accent-soft);
}
.twin-step.is-active .twin-step__n {
  border-color: var(--accent);
  color: var(--accent);
  box-shadow: var(--glow-accent-soft);
}

/* 단계별 phase 색 힌트 — 레이아웃을 건드리지 않도록 inset 그림자 2px 로 표시한다 */
.twin-step[data-phase] { box-shadow: inset 2px 0 0 color-mix(in srgb, var(--cf-phase) 32%, transparent); }
.twin-step.is-done[data-phase] { box-shadow: inset 2px 0 0 color-mix(in srgb, var(--cf-phase) 55%, transparent); }
.twin-step.is-active[data-phase] {
  box-shadow: inset 2px 0 0 var(--cf-phase);
  background: color-mix(in srgb, var(--cf-phase) 13%, transparent);
}
.twin-step.is-active[data-phase] .twin-step__n {
  border-color: var(--cf-phase);
  color: var(--cf-phase);
}

.twin-compare {
  /* 단계 <li>(20px + 1fr 2열 그리드) 안에 JS가 append 한다 — 열 스팬이 없으면
     20px 번호 열에 끼어 글자가 세로로 눌린다. */
  grid-column: 1 / -1;
  display: grid;
  gap: 4px;
  margin: 5px 0 1px;
  padding: var(--sp-2) var(--sp-3);
  background: var(--surface-2);
  border-radius: var(--radius-sm);
}
.twin-compare__row { display: flex; justify-content: space-between; gap: var(--sp-3); font-size: var(--fs-xs); }
.twin-compare__row dt { color: var(--text-faint); }
.twin-compare__row dd { margin: 0; font-weight: 500; font-family: var(--font-mono); }

/* 실시간 연결 스트립 — 선택 기기 ⇄ 통신 대상 */
.twin-linkviz {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: var(--sp-3);
  margin-bottom: var(--sp-3);
  background: var(--surface-2);
  border-radius: var(--radius-sm);
}
.twin-linkviz[hidden] { display: none; }
.twin-linkviz__me {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border-radius: var(--radius-pill);
  background: var(--accent-soft);
  border: 1px solid var(--border-accent);
  font-size: var(--fs-xs);
  font-weight: 600;
  white-space: nowrap;
}
.twin-linkviz__arrow { color: var(--text-faint); font-size: var(--fs-sm); }
.twin-linkviz__peers { display: flex; flex-wrap: wrap; gap: 6px; flex: 1 1 auto; }
.twin-linkviz__peer {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 4px 9px;
  border-radius: var(--radius-pill);
  background: var(--surface);
  border: 1px solid var(--border);
  font-size: var(--fs-xs);
  color: var(--text-dim);
  transition: background var(--t-micro), color var(--t-micro), border-color var(--t-micro);
}
.twin-linkviz__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); transition: background var(--t-micro); }
.twin-linkviz__peer.is-active { background: var(--accent-soft); border-color: var(--border-accent); color: var(--text); }
.twin-linkviz__peer.is-active .twin-linkviz__dot { background: var(--accent); box-shadow: var(--glow-accent-soft); animation: twin-pulse-dot 0.8s var(--ease-micro); }
@keyframes twin-pulse-dot {
  0% { transform: scale(1); }
  40% { transform: scale(2.1); }
  100% { transform: scale(1); }
}

/* 실시간 NGSI-LD 엔티티 카드 */
.twin-entity {
  margin-bottom: var(--sp-3);
  padding: var(--sp-3);
  background: var(--bg-deep);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.twin-entity[hidden] { display: none; }
.twin-entity__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2);
  margin-bottom: 6px;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--border);
}
.twin-entity__type { font-family: var(--font-mono); font-size: var(--fs-xs); font-weight: 700; color: var(--accent); }
.twin-entity__id { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.twin-entity__attrs { display: grid; gap: 4px; }
.twin-entity__row { display: grid; grid-template-columns: auto 18px 1fr; align-items: center; gap: 7px; font-size: 11px; }
.twin-entity__name { font-family: var(--font-mono); color: var(--text-dim); white-space: nowrap; }
.twin-entity__badge {
  display: grid;
  place-items: center;
  width: 15px;
  height: 15px;
  border-radius: 4px;
  font-size: 9px;
  font-weight: 700;
  font-family: var(--font-mono);
  background: var(--surface-2);
  color: var(--text-faint);
}
.twin-entity__badge[data-t="R"] { color: var(--accent); background: var(--accent-soft); }
.twin-entity__val {
  font-family: var(--font-mono);
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* 통신 로그 콘솔 */
.twin-log {
  flex: 1 1 auto;
  min-height: 160px;
  max-height: 100%;
  overflow-y: auto;
  padding: var(--sp-2) 0;
  border-top: 1px solid var(--border);
}
.twin-log__empty { padding: var(--sp-4) var(--sp-2); font-size: var(--fs-xs); color: var(--text-faint); text-align: center; }
.twin-log__entry {
  padding: 6px var(--sp-2);
  border-bottom: 1px solid var(--border);
  animation: twin-log-in 220ms var(--ease-enter) both;
}
@keyframes twin-log-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: translateY(0); } }
.twin-log__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2);
  font-size: var(--fs-xs);
}
.twin-log__peer { font-weight: 600; color: var(--text); }
.twin-log__dir { color: var(--text-faint); }
.twin-log__ts { font-family: var(--font-mono); color: var(--text-faint); }
.twin-log__text { margin-top: 1px; font-size: var(--fs-xs); color: var(--text-dim); }
.twin-log__json {
  margin-top: 4px;
  padding: 6px 8px;
  background: var(--bg-deep);
  border-radius: 6px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  line-height: 1.5;
  color: var(--text-dim);
  white-space: pre-wrap;
  word-break: break-all;
}

/* ── 모션 저감 ─────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  .twin-node--drone .twin-node__dot { animation: none; }
  .twin-node--cctv.is-scanning .twin-node__dot::after { animation: none; }
  .twin-event__ring { animation: none; opacity: 0.35; }
  .twin-link.is-on { animation: none; }
  .twin-pop,
  .twin-alert { animation: none; }
  .twin-log__entry { animation: none; }
  .twin-node.is-moving .twin-node__halo { animation: none; opacity: 0.55; }
  .twin-node--drone.is-working .twin-node__work::before { animation: none; }
  .twin-node--drone.is-working .twin-node__work::after { animation: none; }
  .twin-node--robot.is-working .twin-node__work::before,
  .twin-node--robot.is-working .twin-node__work::after { animation: none; opacity: 0.45; transform: scale(2.4); }
  .twin-linkviz__peer.is-active .twin-linkviz__dot { animation: none; }
  .twin-ambient { animation: none; }
  .twin-node--hub .twin-node__halo,
  .twin-node--hub.is-burst .twin-node__halo { animation: none; opacity: 0.32; }
  .twin-hud__stat-dot { animation: none; }
  .twin-callout__bubble { animation: none; }
  .twin-callout.is-pop { animation: none; opacity: 1; transform: translate(-50%, -100%) scale(1); }
  .twin-linelabel { transition: none; }

  /* 데이터 흐름 FX — 빔 점화·패킷 등장 팝·경보 링을 멈춘다.
     패킷 이동 자체는 JS가 좌표로 하므로 TwinFX가 reduced 일 때 패킷 수를 1개로 줄여 대응한다 */
  .tfx-path { animation: none; }
  .tfx-link { animation: none; stroke-dashoffset: 0; stroke-opacity: 1; }
  .tfx-link__pulse { animation: none; display: none; }

  /* 부팅 로딩 오버레이 — 페이드·스캔·스피너를 전부 멈추되 단계 판독은 살린다.
     특히 마크 맥동은 마지막 키프레임이 opacity 0.55 라 정지만 시키면 흐려진 채 굳는다 */
  .twin-boot { transition: none; }
  .twin-boot__inner { animation: none; transition: none; }
  .twin-boot__inner::before { animation: none; opacity: 0; }
  .twin-boot__mark { animation: none; opacity: 1; }
  .twin-boot[data-state="boot"] .twin-boot__progress::after { animation: none; opacity: 0; }
  .twin-boot__fill { transition: none; }
  .twin-boot__step { animation: none; }
  .twin-boot__step[data-state="active"] .twin-boot__step-dot::after { animation: none; opacity: 0; }

  /* 카메라 시점 제어 — 등장 연출과 추적 중 맥동만 멈춘다(색 대비는 그대로 남는다) */
  .twin-camctl { animation: none; }
  .twin-cambtn.is-active .twin-cambtn__state::after,
  .twin-cambtn[aria-pressed="true"] .twin-cambtn__state::after { animation: none; opacity: 1; }

  /* 단계 전역 톤 */
  .twin-stage[data-phase]::after { animation: none; }

  /* 상황 말풍선 톤별 거동 */
  .twin-callout--query .twin-callout__icon::after,
  .twin-callout--success .twin-callout__icon::after,
  .twin-callout--info .twin-callout__icon::after { animation: none; opacity: 0; }
  .twin-callout--process .twin-callout__icon::after { animation: none; }
  .twin-callout--process .twin-callout__bubble::before { animation: none; transform: scaleX(1); }
  .twin-callout--danger .twin-callout__icon,
  .twin-callout--danger .twin-callout__bubble { animation: none; }

  /* 작업 유형별 표식 — 정지 상태에서도 유형이 읽히도록 최종 형태로 고정한다 */
  .twin-node.is-work-record .twin-node__work::before,
  .twin-node.is-work-record .twin-node__work::after,
  .twin-node.is-work-thermal .twin-node__work::after,
  .twin-node.is-work-aid .twin-node__work::before,
  .twin-node.is-work-aid .twin-node__work::after { animation: none; }
  .twin-node.is-work-broadcast .twin-node__work::before,
  .twin-node.is-work-broadcast .twin-node__work::after { animation: none; opacity: 0.45; transform: scale(2.4); }
  .twin-node.is-work-scan .twin-node__work::before { animation: none; opacity: 0.95; }
  .twin-node.is-work-scan .twin-node__work::after { animation: none; }
  .twin-node.is-work-siren .twin-node__work::before,
  .twin-node.is-work-siren .twin-node__work::after { animation: none; opacity: 0.55; }

  /* 감지 스캔 · 순찰 복귀 */
  .twin-node.is-scanning-alert::before { animation: none; opacity: 0.45; transform: scale(1.35); }
  .twin-node.is-scanning-alert::after { animation: none; opacity: 0; }
  .twin-node.is-returning::after { animation: none; opacity: 0.3; transform: scale(1.1); }

  /* 기기 등장·퇴장 — 연출만 끄고 최종 상태는 유지한다(퇴장은 JS가 hidden 을 걸 때까지 보인다) */
  .twin-node.is-revealing { animation: none; }
  .twin-node.is-revealing::after { animation: none; opacity: 0; }
  .twin-node.is-concealing { animation: none; opacity: 0.4; }

  /* 말풍선 교체 — JS가 reduced 일 때 is-swap 을 건너뛰지만 방어로 남긴다 */
  .twin-callout.is-swap { animation: none; opacity: 1; }

  /* 도크 큐 UI */
  .twin-gen__menu { animation: none; }
  .twin-queue__progress { animation: none; border-top-color: var(--accent); }
}

/* ── 반응형 ────────────────────────────────────────────────── */
@media (max-width: 1180px) {
  .twin-aside { width: 300px; }
  .twin-dock { width: 280px; }
  .twin-gen__menu { width: 264px; }
  .twin-flowlegend { gap: 3px var(--sp-2); padding: var(--sp-2) var(--sp-3); }
}

/* RTL(R10) — 엔티티 카드·통신 로그 페이로드는 코드 표기라 좌→우 고정 */

/* CCTV 실시간 영상(R11-c) — 팝오버의 '실시간 영상' 버튼으로 여는 스테이지 우하단 패널(MockData.CCTV_LIVE.enabled 일 때만 버튼이 생긴다) */
.twin-pop__live { margin-top: var(--sp-3); width: 100%; }
.twin-live {
  position: absolute;
  right: var(--sp-4);
  bottom: 88px;
  z-index: 24;
  width: min(440px, calc(100% - 32px));
  background: var(--surface-glass-strong);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  backdrop-filter: blur(14px);
  overflow: hidden;
  animation: twin-pop-in var(--dur-micro) var(--ease-enter) both;
}
.twin-live[hidden] { display: none; }
.twin-live__head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); font-size: 12px; font-weight: 600; }
.twin-live__video { display: block; width: 100%; aspect-ratio: 16 / 9; background: #000; }
.twin-live__src { margin: 0; padding: var(--sp-1) var(--sp-3) var(--sp-2); font-size: 11px; color: var(--text-muted); }
