/* =========================================================================
   LIVE CITY HALL BUS FLOW — 지도 우선(full-bleed) 레이아웃
   Layer 1 primitive → Layer 2 semantic → Layer 3 component 순서로만 값을 쓴다.
   컴포넌트 규칙에서 raw hex 를 직접 쓰지 않는다.
   ========================================================================= */

:root {
  /* ---- Layer 1 · primitive ---------------------------------------- */
  --blue-600: #1257c4;
  --blue-700: #0e4499;
  --slate-900: #10202e;
  --slate-600: #4a5a68;
  --slate-300: #c6d0d9;
  --white: #ffffff;
  --green-700: #0f7b4f;
  --amber-800: #8a5200;
  --red-700: #b3261e;

  /* ---- Layer 2 · semantic ------------------------------------------ */
  --color-primary: var(--blue-600);
  --color-primary-hover: var(--blue-700);
  --color-surface: rgb(255 255 255 / 94%);
  --color-surface-solid: var(--white);
  --color-on-surface: var(--slate-900);
  --color-on-surface-muted: var(--slate-600);
  --color-border: var(--slate-300);
  --color-success: var(--green-700);
  --color-warning: var(--amber-800);
  --color-error: var(--red-700);
  --color-idle: var(--slate-600);
  --color-focus-ring: var(--blue-700);
  --color-primary-soft: rgb(18 87 196 / 8%);

  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;

  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;

  --shadow-1: 0 1px 2px rgb(16 32 46 / 10%);
  --shadow-2: 0 8px 24px rgb(16 32 46 / 18%);

  --duration-fast: 150ms;
  --duration-normal: 250ms;
  --ease-out: cubic-bezier(0, 0, 0.2, 1);

  --z-panel: 1000;
  --z-topbar: 1100;

  --font-sans: Inter, "Pretendard", system-ui, -apple-system, "Segoe UI", sans-serif;

  /* ---- Layer 3 · component ----------------------------------------- */
  --topbar-height: 68px;
  --panel-width: 300px;
  /* 지도에 남겨 두려는 세로 띠. app.js 의 MIN_RING_PX(120) 짜리 링이 위아래 여백과
     함께 들어가는 크기다. 이 값이 없으면 짧은 화면에서 하단 스택이 60dvh 를 그대로
     먹어 띠가 36 px 만 남았다(360×400 실측).
     보장이 아니라 선호다 — 아래 .panels 상한에서 112 px 하한이 이기는 구간
     (뷰포트 높이 376 px 미만)에서는 띠가 이 값에 못 미친다. 그 구간은 app.js 의
     스케일 클램프가 받는다. */
  --map-band-min: 168px;
  --marker-bg: var(--color-primary);
  --marker-outline: var(--color-surface-solid);
  /* 방향성 마커. 4자리 노선번호(예: 6512)가 흰 테두리 안에 들어가는 최소 크기다 —
     11 px/700 tabular 로 26 px, 좌우 여백 포함 28 px 가 사각부 폭이어야 한다. */
  --marker-w: 34px;
  --marker-h: 42px;
  --marker-stroke: 3px;
  /* 바깥 어두운 가장자리. 흰 테두리만으로는 흰 도로·건물 위에서 윤곽 대비가
     1.00:1 이 된다(실측). 1 px·70 %% 로는 대각선에서 AA 에 희석돼 1.20:1 이었다.
     85 %% 도 같은 이유로 불투명으로 올렸다 — 2 px 띠는 회전한 경계에서 픽셀이 부분만
     덮이므로, 시작색이 옅으면 그 혼합이 지면 쪽으로 끌려간다. 실제 배경지도 48 지점
     8,296 표본에서 3:1 미만이 3개 -> 1개, 밝은 지면 중앙값 9.13 -> 12.61:1. */
  --marker-edge-w: 2px;
  --marker-edge: var(--slate-900);
  --marker-font: 11px;
  --marker-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 17V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v11'/%3E%3Cpath d='M4 11h16'/%3E%3Cpath d='M4 17h16'/%3E%3Ccircle cx='7.5' cy='17' r='1.5'/%3E%3Ccircle cx='16.5' cy='17' r='1.5'/%3E%3C/svg%3E");
  /* 위치 출처 표기용 글리프 2종. .icon 인라인 SVG 와 같은 가족(Lucide,
     24 그리드, stroke 1.5, round cap)이고 전달만 mask 다 — .detail-source 는 app.js 가
     마크업을 쓰는 자리라서 그쪽이 SVG 노드를 내보내지 않아도 되게 했다.
     mask 는 alpha 만 쓰므로 stroke 색은 의미가 없다(currentColor 로 칠한다). */
  --icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
  --icon-pin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0'/%3E%3Ccircle cx='12' cy='10' r='3'/%3E%3C/svg%3E");
  --button-fg: var(--color-primary);
  --button-border: var(--color-primary);
  --button-bg-hover: var(--blue-600);
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100dvh;
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.5;
  color: var(--color-on-surface);
  background: var(--color-surface-solid);
  overflow: hidden; /* 지도가 뷰포트를 채우므로 문서 스크롤은 쓰지 않는다 */
}

/* ---- 지도: 뷰포트 전체 ---------------------------------------------- */
#map {
  position: fixed;
  inset: 0;
  background: var(--color-surface-solid);
}

/* ---- skip link ------------------------------------------------------- */
.skip {
  position: absolute;
  left: -999px;
}

.skip:focus {
  left: var(--space-3);
  top: var(--space-3);
  z-index: calc(var(--z-topbar) + 1);
  padding: var(--space-3);
  border-radius: var(--radius-md);
  background: var(--color-surface-solid);
  color: var(--color-on-surface);
  box-shadow: var(--shadow-2);
}

/* ---- 상단 바 --------------------------------------------------------- */
.topbar {
  position: fixed;
  inset: 0 0 auto;
  z-index: var(--z-topbar);
  display: flex;
  align-items: center;
  gap: var(--space-4);
  min-height: var(--topbar-height);
  padding: var(--space-2) var(--space-4);
  border-bottom: 1px solid var(--color-border);
  background: var(--color-surface);
  backdrop-filter: blur(8px);
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-width: 0;
}

.brand-icon {
  flex: none;
  color: var(--color-primary);
}

.brand-text {
  min-width: 0;
}

h1 {
  margin: 0;
  font-size: 18px;
  line-height: 1.2;
  font-weight: 700;
  letter-spacing: 0.01em;
  white-space: nowrap;
}

.brand-sub {
  margin: 2px 0 0;
  font-size: 13px;
  line-height: 1.3;
  color: var(--color-on-surface-muted);
  white-space: nowrap;
}

/* ---- 상태 표시 ------------------------------------------------------- */
.status {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-left: auto;
  font-size: 14px;
  color: var(--color-on-surface-muted);
}

.status-label {
  font-weight: 600;
  color: var(--color-on-surface);
}

.divider {
  color: var(--color-on-surface-muted);
}

.metric {
  margin-left: var(--space-3);
  color: var(--color-on-surface);
  font-variant-numeric: tabular-nums;
}

.metric strong {
  font-size: 18px;
}

.metric-unit {
  margin-left: var(--space-1);
  font-size: 14px;
  color: var(--color-on-surface-muted);
}

/* 상태 점은 색 + 형태로 구분한다 (색 단독 의미 전달 금지).
   app.js 가 `dot` / `dot ok` / `dot warn` / `dot err` 네 가지를 모두 쓴다. */
.dot,
.state-swatch {
  width: 12px;
  height: 12px;
  flex: none;
  display: inline-block;
  border: 2px solid var(--color-idle);
  border-radius: 50%;
  background: transparent;
}

.dot.ok,
.state-swatch.state-ok {
  border-color: var(--color-success);
  background: var(--color-success);
}

.dot.warn,
.state-swatch.state-warn {
  border-color: var(--color-warning);
  background: var(--color-warning);
  border-radius: var(--radius-sm);
  transform: rotate(45deg);
}

.dot.err,
.state-swatch.state-err {
  border-color: var(--color-error);
  background: var(--color-error);
  border-radius: 2px;
}

/* ---- 버튼 ------------------------------------------------------------ */
button {
  flex: none;
  min-width: 96px;
  min-height: 44px;
  padding: 0 var(--space-4);
  border: 1px solid var(--button-border);
  border-radius: var(--radius-md);
  background: var(--color-surface-solid);
  color: var(--button-fg);
  font-family: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-out),
    color var(--duration-fast) var(--ease-out);
}

button:hover {
  background: var(--button-bg-hover);
  color: var(--color-surface-solid);
}

button[aria-pressed="true"] {
  background: var(--color-primary);
  color: var(--color-surface-solid);
}

a:focus-visible,
button:focus-visible,
summary:focus-visible,
.marker:focus-visible,
.maplibregl-canvas:focus-visible {
  outline: 3px solid var(--color-focus-ring);
  outline-offset: 2px;
}

/* ---- 오버레이 패널 (접기 가능) --------------------------------------- */
/* 데스크톱에서는 래퍼가 레이아웃에 관여하지 않고, 각 패널이 모서리에 고정된다.
   모바일에서는 래퍼가 하단 스택이 된다(아래 미디어 쿼리). */
.panels {
  display: contents;
}

.panel {
  position: fixed;
  z-index: var(--z-panel);
  width: var(--panel-width);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  background: var(--color-surface);
  backdrop-filter: blur(8px);
  box-shadow: var(--shadow-2);
}

.panel-detail {
  top: calc(var(--topbar-height) + var(--space-4));
  right: var(--space-4);
}

.panel-legend {
  left: var(--space-4);
  bottom: var(--space-4);
}

/* 앱 카드의 머리글. `summary` 를 그대로 쓰면 MapLibre 어트리뷰션의 ⓘ 토글
   (summary.maplibregl-ctrl-attrib-button)까지 잡아 24 px 버튼이 32×44 로 부풀고
   24 px 바 밖으로 삐져나갔다. 선택자를 앱 패널로 한정한다. */
.panel > summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  min-height: 44px;
  padding: 0 var(--space-4);
  border-radius: var(--radius-lg);
  list-style: none;
  cursor: pointer;
  font-size: 14px;
  font-weight: 700;
  color: var(--color-on-surface);
  transition: background-color var(--duration-fast) var(--ease-out);
}

.panel > summary::-webkit-details-marker {
  display: none;
}

.panel > summary:hover {
  background: var(--color-primary-soft);
}

.chevron {
  flex: none;
  color: var(--color-on-surface-muted);
  transition: transform var(--duration-fast) var(--ease-out);
}

.panel[open] > summary .chevron {
  transform: rotate(180deg);
}

.panel-body {
  max-height: 46dvh;
  overflow-y: auto;
  padding: 0 var(--space-4) var(--space-4);
  border-top: 1px solid var(--color-border);
  padding-top: var(--space-3);
  font-size: 14px;
}

.panel-body h2 {
  margin: 0 0 var(--space-2);
  font-size: 18px;
  line-height: 1.3;
}

.panel-body p {
  margin: 0 0 var(--space-2);
  color: var(--color-on-surface-muted);
  max-width: 40ch;
}

.panel-body .eyebrow {
  margin: 0 0 var(--space-1);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.12em;
  color: var(--color-primary);
}

/* ---- 범례 ------------------------------------------------------------ */
.legend-list {
  margin: 0 0 var(--space-3);
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--space-2);
}

.legend-list li {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  color: var(--color-on-surface);
}

/* 범례에서는 히트 타겟이 필요 없으므로 몸통 크기에 맞춘다.
   마크업과 크기 토큰은 지도와 같다. 폰트는 같지 않다 — 지도 마커는 .maplibregl-map 이
   선언한 maplibre 스택(Helvetica Neue/Arial)을 상속받고, 이 견본은 문서 흐름 안에 있어
   --font-sans 을 상속받는다. 같은 글자 실측 402 20.92 vs 17.70 px · M4137 37.39 vs
   32.55 px (2026-09-21). labelWidth() 는 계산된 스타일에서 푸므로 자와 칠은 여전히
   일치한다 — 어긋난 것은 "범례 = 지도" 라는 주장 쪽이다. */
.legend-swatch {
  flex: none;
  width: 44px;
  height: 44px;
}

/* 상태 견본 두 개가 한 줄에 들어갈 때 */
.legend-swatches {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* 지도에 그려지는 500 m 기준 원(점선 링 + 옅은 채움)과 같은 토큰을 쓴다 */
.ring-swatch {
  flex: none;
  width: 20px;
  height: 20px;
  border: 2px dashed var(--color-primary);
  border-radius: 50%;
  background: var(--color-primary-soft);
}

.legend-units {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-1) var(--space-3);
  margin: 0 0 var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-border);
  font-size: 13px;
}

.legend-units dt {
  color: var(--color-on-surface-muted);
}

.legend-units dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

.note {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--color-on-surface-muted);
}

/* ---- 위치 출처 표기 -------------------------------------------------
   계약은 속성 하나다: body[data-position-source] = 'observed' | 'estimated'
   (app.js 의 applyPositionSource() 가 쓴다). 속성이 없거나 'observed' 면
   화면은 이전과 한 픽셀도 다르지 않다 — 아래 세 규칙이 전부 꺼져 있다.

   마커마다 배지를 붙이지 않는다. 도착정보 피드에서는 화면의 버스가 전부
   추정이라 100 %에 붙는 표시는 아무것도 가르지 못하면서, 44 px 예약과
   지면 윤곽 대비 4.08:1 을 그대로 긎는다. 전역 표기 네 곳으로 대신한다 —
   ① 상단 바 칩 ② 범례 줄 ③ 범례 하단 주석 ④ 차량 상세 줄(.detail-source). */
.source-chip {
  display: none;
  flex: none;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--color-primary);
  border-radius: var(--radius-sm);
  background: var(--color-primary-soft);
  /* --color-primary 가 아니라 한 단계 어두운 쪽을 쓴다. 13 px/600 한글 획은
     어느 픽셀도 100 % 덤지 않아, 선언 5.80:1 이 실제 칠에서 4.06:1 까지 내려갔다
     (펌색 바탕 rgb(235 241 248) 위 실측). 이 토큰은 선언 8.01:1 이라 칠한 카운터가
     4.5 위에 남는다. hover 상태가 아니라 이름이 알맞지는 않지만, 같은 파랑의
     진한 단계로 시트에 이미 있는 값이라 새 토큰을 만들지 않았다. */
  color: var(--color-primary-hover);
  font-size: 13px;
  line-height: 1.3;
  font-weight: 600;
  white-space: nowrap;
}

/* 아이콘은 장식이다 — 뜻은 옆의 글자가 전부 갖고 있다(색·모양 단독 전달 금지).
   mask 는 접근성 트리에 안 들어가므로 aria-hidden 을 따로 적을 필요가 없다. */
.source-chip::before {
  content: "";
  flex: none;
  width: 16px;
  height: 16px;
  background: currentColor;
  mask: var(--icon-info) center / contain no-repeat;
}

/* .legend-list li 가 (0,1,1) 로 display: flex 를 선언하므로 .legend-estimated (0,1,0)
   하나로는 못 끕는다 — 처음에 그렇게 썼고, observed 모드에서 줄이 그대로
   보였다(실측: 픽셀 diff 17,604 · 대조군 같은 빌드 두 번은 0). 리스트 규칙과 같은
   모양으로 짚는다. */
.legend-list li.legend-estimated,
.note[data-note-for="estimated"],
.brand-sub [data-sub-for="estimated"] {
  display: none;
}

body[data-position-source="estimated"] .source-chip {
  display: inline-flex;
}

body[data-position-source="estimated"] .legend-list li.legend-estimated {
  display: flex; /* .legend-list li 와 같은 표시 방식으로 돌려놓는다 */
}

body[data-position-source="estimated"] .note[data-note-for="estimated"] {
  display: block;
}

body[data-position-source="estimated"] .note[data-note-for="observed"] {
  display: none;
}

body[data-position-source="estimated"] .brand-sub [data-sub-for="estimated"] {
  display: inline;
}

body[data-position-source="estimated"] .brand-sub [data-sub-for="observed"] {
  display: none;
}

/* ④ 차량 상세 패널의 위치 출처 줄.
   마크업 계약: app.js 가 #detail-body 안에 한 줄로 내보낸다 —
     <p class="detail-source">위치: …</p>
   상자·아이콘은 여기서 붙이므로 app.js 는 문구만 넣으면 된다.
   추정·실측 둘 다 같은 상자를 쓴다. 가르는 것은 상자가 아니라 그 안의 문구고,
   이 줄이 하는 일은 그 문구가 7줄짜리 <br> 목록 속에 섞이지 않게 하는 것이다.
   모드로 상자를 가르지 않는 이유: 같은 사실("이 좌표가 무엇인가")이 모드마다 다른
   모양으로 그려지면 출처가 바뀌는 순간 패널이 다시 배우는 화면이 된다.

   칠한 잉크 대비 실측(.scratch/design-slot-contrast.mjs — app.js 가 그린 마크업을
   마커를 눌러서 띄우고, 같은 프레임에서 글자만 투명하게 만든 촬영과 차분):
     estimated 「위치: 추정 위치 (GPS 좌표 아님) — …」  코어 최저 6.39:1 · 중앙값 10.68
     observed  「위치: 실측 GPS 좌표」                    코어 최저 6.70:1 · 중앙값 10.67
   테두리는 지면 대비 1.36:1 로 3:1 아래다. 이 테두리는 정보를 나르지 않는다 —
   뜻은 전부 글자에 있고(6.39:1 위) 아이콘은 장식이다. 상자는 목록과의 분리용이라
   경계선을 진하게 올리는 대신 배경 틴트와 여백으로 가른다. */
/* .panel-body p (0,1,1) 가 muted 색을 선언하므로 같은 특이도로는 못 이긴다.
   이 줄은 공개(disclosure)라 본문 색을 쓴다 — 패널 컨테이너로 한정해서 올린다. */
.panel-body .detail-source {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  margin: 0 0 var(--space-3);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-primary-soft);
  color: var(--color-on-surface);
  font-size: 13px;
  line-height: 1.5;
}

.panel-body .detail-source::before {
  content: "";
  flex: none;
  width: 16px;
  height: 16px;
  margin-top: 2px; /* 13px/1.5 한 줄의 시각 중심에 글리프를 맞춘다 */
  background: var(--color-primary);
  mask: var(--icon-pin) center / contain no-repeat;
}

/* ---- 버스 마커: 로우폴리 3D 버스 -------------------------------------
   요청(dhl, 2026-09-21): 직육면체 수준의 3D 버스, 노선번호는 지붕에.
   (Dev-Bot 이 기한 때문에 이 블록을 교체했다 — Design-Bot 검토 대상.)

   app.js 계약:
     <button class="marker" data-state="moving" data-heading="known"
             [data-code="wide"] [data-label-flip] aria-pressed="false" aria-label="…">
       <span class="marker-body" aria-hidden="true">
         <span class="marker-wall" data-wall="front|back|left|right"></span> ×4
         <span class="marker-face"></span>      지붕
         <span class="marker-label">402</span>  지붕 위 노선번호
       </span>
     </button>
   버튼은 지면에 눕힌 44×44 히트 타겟이다. MapLibre 가 요소 전체를 지도와 함께
   기울인다(pitchAlignment: map). 진행 방향 회전은 app.js 가 --bus-turn(헤딩 − 지도 방위)
   으로 몸통에만 준다. 몸통은 preserve-3d 라 3D 모드에서 옆면이 보인다.
   filter · opacity · clip-path · overflow 는 3D 를 평면으로 눌러 버리므로
   .marker 와 .marker-body 에 쓰지 않는다. */
.marker {
  --bus-len: 42px;
  --bus-w: 20px;
  --bus-h: 12px;
  --bus-roof: var(--marker-bg);
  --bus-roof-ink: var(--marker-outline);
  --bus-wall: color-mix(in srgb, var(--marker-bg) 72%, var(--slate-900));
  --bus-glass: var(--slate-900);
  --bus-shadow: rgb(16 32 46 / 32%);
  --bus-label-turn: -90deg;
  position: relative;
  /* 히트 타겟은 화면에 똑바로 선 44×44 다. 버스와 함께 돌지 않는다(몸통만 돈다).
     app.js 가 지도 컨테이너에 --marker-hit-h 를 44/cos(pitch) 로 넣는다 — 3D 화면에서
     MapLibre 가 요소를 기울여 세로로 cos(pitch) 만큼 눌러도 44 px 가 남는다. */
  width: 44px;
  height: var(--marker-hit-h, 44px);
  /* 전역 button 규칙(min-width 96 / min-height 44)을 마커에서는 해제한다 */
  min-width: 0;
  min-height: 0;
  padding: 0;
  border: 0;
  /* 전역 button 의 둥근 모서리(8 px)가 히트 박스 네 귀퉁이를 깎는다. 마커는 보이는
     상자가 아니므로 각지게 두어 44×44 전체를 누를 수 있게 한다. */
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  transform-style: preserve-3d;
}

/* MapLibre 는 마커를 transform 하나로 앉힌다. position: relative 가 이기면
   문서 흐름 위치가 더해져 마커가 줄줄이 밀린다(23대 기준 968 px 실측). */
#map .marker {
  position: absolute;
  top: 0;
  left: 0;
}

button.marker {
  cursor: pointer;
}

/* 전역 button:hover · button[aria-pressed] 의 파란 바탕이 히트 박스를 칠하지 않게 */
button.marker:hover,
.marker[aria-pressed="true"] {
  background: transparent;
}

/* 지면 그림자. 흰 도로 위에서 차체의 윤곽을 받쳐 준다. 몸통과 같이 돈다. */
.marker-body::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius-sm);
  background: var(--bus-shadow);
  box-shadow: 0 0 4px 2px var(--bus-shadow);
}

.marker-body {
  position: absolute;
  inset: 0;
  width: var(--bus-w);
  height: var(--bus-len);
  margin: auto;
  transform: rotate(var(--bus-turn, 0deg));
  transform-style: preserve-3d;
  pointer-events: none;
}

/* 네 벽. 각 벽은 지면에 누운 띠를 한 변을 축으로 90° 세운 것이다. */
.marker-wall {
  position: absolute;
  box-sizing: border-box;
  border: 1px solid var(--marker-edge);
  background: var(--bus-wall);
}

.marker-wall[data-wall="left"],
.marker-wall[data-wall="right"] {
  top: 0;
  width: var(--bus-h);
  height: var(--bus-len);
}

.marker-wall[data-wall="left"] {
  left: 0;
  transform-origin: 0 50%;
  transform: rotateY(-90deg);
}

.marker-wall[data-wall="right"] {
  right: 0;
  transform-origin: 100% 50%;
  transform: rotateY(90deg);
}

.marker-wall[data-wall="front"],
.marker-wall[data-wall="back"] {
  left: 0;
  width: var(--bus-w);
  height: var(--bus-h);
}

/* 앞면은 전면 유리. 진행 방향은 이 면과 지붕 앞 유리띠(형태)로 읽힌다. */
.marker-wall[data-wall="front"] {
  top: 0;
  transform-origin: 50% 0;
  transform: rotateX(90deg);
  background: var(--bus-glass);
}

.marker-wall[data-wall="back"] {
  bottom: 0;
  transform-origin: 50% 100%;
  transform: rotateX(-90deg);
}

/* 지붕 */
.marker-face {
  position: absolute;
  inset: 0;
  box-sizing: border-box;
  border: 2px solid var(--marker-edge);
  border-radius: 3px;
  background: var(--bus-roof);
  transform: translateZ(var(--bus-h));
  transition: background-color var(--duration-fast) var(--ease-out);
}

/* 지붕 앞쪽 유리띠 — 위에서 내려다보는 2D 화면에서 앞뒤를 가른다 */
.marker-face::before {
  content: "";
  position: absolute;
  inset: 1px 1px auto;
  height: 5px;
  border-radius: 2px;
  background: var(--bus-glass);
}

/* 지붕 위 노선번호. 차체 길이 방향으로 쓴다(지붕 폭 20 px 에는 4자리가 안 들어간다).
   라벨 상자를 차체 길이×폭으로 잡고 90° 돌려 지붕에 맞춘다. 남쪽을 넘어가면 거꾸로
   읽히므로 app.js 가 화면 각도로 data-label-flip 을 켜서 반대로 돌린다. */
.marker-label {
  position: absolute;
  top: calc((var(--bus-len) - var(--bus-w)) / 2);
  left: calc((var(--bus-w) - var(--bus-len)) / 2);
  display: grid;
  place-items: center;
  width: var(--bus-len);
  height: var(--bus-w);
  /* 유리띠(5 px)를 피해 뒤쪽으로 2 px 민다 */
  transform: translateZ(calc(var(--bus-h) + 1px)) rotate(var(--bus-label-turn)) translateX(calc(var(--bus-label-shift, -2px)));
  color: var(--bus-roof-ink);
  font-size: var(--marker-font);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  pointer-events: none;
}

.marker[data-label-flip] {
  --bus-label-turn: 90deg;
  --bus-label-shift: 2px;
}

/* 5글자 이상·한글 코드(M4137, 종로09)는 지붕 길이에 맞춰 한 단계 작게 */
.marker[data-code="wide"] {
  --marker-font: 10px;
}

button.marker:hover {
  --bus-roof: var(--color-primary-hover);
}

/* 상태는 색이 아니라 채움으로 가른다(색 단독 금지).
     운행 중        채운 지붕
     정차           속 빈 지붕(흰 바탕 + 두꺼운 테두리) — 번호는 테두리색
     운행 종료·끊김  저채도 + 점선 테두리 */
.marker[data-state="stopped"],
.marker[data-state="ended"] {
  --bus-roof: var(--marker-outline);
  --bus-roof-ink: var(--marker-bg);
}

.marker[data-state="stopped"] .marker-face,
.marker[data-state="ended"] .marker-face {
  border-width: 3px;
  border-color: var(--marker-bg);
}

.marker[data-state="ended"] {
  --marker-bg: var(--color-idle);
}

.marker[data-state="ended"] .marker-face {
  border-style: dashed;
}

button.marker[data-state="stopped"]:hover,
button.marker[data-state="ended"]:hover {
  --bus-roof: var(--color-primary-soft);
}

/* 방향이 관측되지 않은 차량. 유리는 "이쪽으로 간다"는 주장이므로 뗀다 —
   앞뒤가 같은 상자는 축만 말한다. app.js 가 이 차량을 화면 가로로 눕힌다. */
.marker[data-heading="unknown"] .marker-face::before {
  content: none;
}

.marker[data-heading="unknown"] .marker-wall[data-wall="front"] {
  background: var(--bus-wall);
}

/* 선택된 차량. 지면에 링을 둘러 형태로 구분한다(app.js 가 aria-pressed 를 켠다).
   누가 위로 오는지는 app.js 의 인라인 z-index 가 정한다. */
.marker[aria-pressed="true"] .marker-body::before {
  box-shadow: 0 0 0 3px var(--marker-outline), 0 0 0 6px var(--color-focus-ring);
}

/* 범례 견본: 지도 밖이라 MapLibre 가 돌리고 기울이지 않는다. 가로로 눕히고
   45° 기울여 지도 3D 화면과 같은 모양을 보인다. */
.legend-swatch .marker-body {
  transform: rotateX(45deg) rotateZ(90deg);
}

/* ---- 아이콘 ----------------------------------------------------------- */
.icon {
  display: block;
}

.icon-sm {
  width: 16px;
  height: 16px;
}

.icon-md {
  width: 20px;
  height: 20px;
}

/* ---- MapLibre 컨트롤이 상단 바에 가리지 않도록 ------------------------- */
.maplibregl-ctrl-top-left,
.maplibregl-ctrl-top-right {
  top: var(--topbar-height);
}

/* 앱 버튼 규칙(min-width 96)이 MapLibre 줌·나침반 버튼까지 잡아 96×132 슬래브가
   됐다. 지도 크롬은 MapLibre 자기 모양을 쓰되 터치 타겟(44×44)만 맞춘다.
   폭이 96 → 44 로 줄면 링 프레이밍이 크롬에 내주는 패딩도 그만큼 줄어든다. */
.maplibregl-ctrl button {
  min-width: 44px;
  width: 44px;
  min-height: 44px;
  height: 44px;
  padding: 0;
  border-radius: 0;
  background: transparent; /* 흰 바탕은 그룹이 갖는다 */
  color: var(--color-on-surface);
}

.maplibregl-ctrl button:hover {
  background: var(--color-primary-soft);
  color: var(--color-on-surface);
}

/* hover 틴트가 그룹의 둥근 모서리 밖으로 네모나게 삐져나오지 않도록 끝 버튼만
   같은 반경을 준다. 그룹에 overflow: hidden 을 주면 포커스 링(바깥 3 px)이 잘린다. */
.maplibregl-ctrl-group button:first-child {
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}

.maplibregl-ctrl-group button:last-child {
  border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}

/* 어트리뷰션 ⓘ 토글은 MapLibre 가 24 px 로 그린다. 보이는 크기는 그대로 두고
   히트 영역만 44×44 로 넓힌다(바 아래 여백이 10 px 이라 화면 밖으로 나가지 않는다). */
.maplibregl-ctrl-attrib-button::after {
  content: "";
  position: absolute;
  inset: -10px;
}

/* MapLibre 4.7.1 은 화면 폭과 무관하게 compact 어트리뷰션을 기본으로 쓰고
   (AttributionControl 기본 옵션이 `{compact: true}`), 지도를 조작하는 순간 스스로
   접어 ⓘ 하나만 남긴다(_updateCompactMinimize). 즉 표기 줄은 로드 직후에만 보이고
   첫 팬 이후로는 사라진다 — 1440×900 에서도 그렇다(실측 bar 410×24 → 24×24).
   ⓘ 뒤로 숨기는 것은 자리가 모자란 화면의 예외이지 데스크톱의 기본이 아니다.
   카드가 모서리에 앉는 폭(≥761)에서는 접힌 상태에서도 표기 줄을 그대로 보여준다.
   좁은 폭·가로 모드는 ⓘ 를 그대로 둔다 — 거기서는 접기가 지도 띠를 54 px 돌려준다.
   그래서 조건이 둘이다: 폭 ≥761(카드가 모서리로 가서 지도를 안 덮음) 그리고
   높이 ≥501(레일 구간 제외 — 거기서는 세로가 모자란 쪽이라 접기가 실익이다).
   MapLibre 는 접을 때 compact-show 클래스만 떼고 <details open> 은 남기므로
   (실측: 드래그 후에도 open=true) 이 규칙만으로 글자가 살아난다. */
@media (min-width: 761px) and (min-height: 501px) {
  .maplibregl-ctrl-attrib.maplibregl-compact {
    padding: 2px 8px; /* 두 상태를 한 박스로. ⓘ 자리(우측 24 px)를 비워 둘 이유가 없다 */
  }

  .maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner {
    display: block;
  }

  /* ⓘ 는 누를 일이 없어진다. display: none 이라 탭 순서에서도 같이 빠진다
     (화면에 없는 컨트롤이 포커스를 받는 상태를 만들지 않는다).
     컨테이너 ::after 는 forced-colors 에서만 ⓘ 글리프를 그린다 — 같이 끈다. */
  .maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,
  .maplibregl-ctrl-attrib.maplibregl-compact::after {
    display: none;
  }
}

.maplibregl-popup-content {
  color: var(--color-on-surface);
}

/* ---- 반응형 ----------------------------------------------------------- */
@media (max-width: 1100px) {
  .brand-sub {
    display: none;
  }
}

/* 상단 바는 폭에 따라 감싼다 — 높이와 무관하다. 가로 모드에서도 740 px 폭에는
   제목·상태·버튼이 한 줄에 들어가지 않아, 제목이 상태 위로 겹쳐 찍혔다(568×320 실측).
   패널 레이아웃(아래 두 블록)과 분리해서 폭 기준으로만 적용한다. */
@media (max-width: 760px) {
  :root {
    /* 상단 바가 2줄로 감싸므로 오버레이 오프셋 기준도 함께 키운다.
       실측 높이가 96 px 이므로 토큰도 96 px 로 맞춘다(92 였을 때 가로 모드 레일이
       상단 바 아래로 4 px 파고들었다). */
    --topbar-height: 96px;
  }

  .topbar {
    flex-wrap: wrap;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-3);
  }

  h1 {
    font-size: 16px;
  }

  .status {
    order: 3;
    width: 100%;
    margin-left: 0;
    flex-wrap: wrap;
    font-size: 13px;
    row-gap: var(--space-1);
  }

  .divider {
    display: none;
  }

  .metric {
    margin-left: auto;
  }
}

/* 칩이 상태줄에 들어가면 좁은 화면에서 상태줄이 한 줄 늘어난다(상단 바 96 -> 126.9 px).
   --topbar-height 는 오버레이(패널·MapLibre 컨트롤)가 상단 바를 피하는 기준이라,
   실제 높이보다 작으면 그것들이 바 밑으로 파고든다. 그래서 칩이 있을 때만 같이 올린다.

   라벨 목록은 여기 적지 않는다. app.js 가 쓰는 집합은 test/server.test.js 의
   'the status bar writes only the labels the width sweep covered' 가 붙들고, 바뀌면
   재측정 명령과 함께 떨어진다. 전에 이 자리에 적어 둔 4종은 라벨이 바뀐 뒤
   사실과 어긋나 있었다 — 같은 값을 두 군데 적으면 한 쪽이 조용히 낡는다.

   임계 실측(app.js dfcbef57 / styles.css 749bddea,
   .scratch/design-topbar-natural.mjs, 2,160 판독 · 폭 320~460 을 1 px 씩):
     estimated  자연 높이 126.9 px(3줄) = 320~422 px,  96 px(2줄) = 423 px 이상
     토큰보다 높은 판독 0 / 2,160 (가림 없음), 가로 스크롤 0
   즉 423~439 px 에서는 토큰이 필요보다 32 px 크다. 그 값은 오버레이가 지도를
   32 px 덜 쓰는 비용이고(428·430 px 은 흔한 단말 폭이다), 가림은 아니다.
   임계를 422 로 내리지 않은 이유: 줄바꿈은 글자 폭이 정하는데 이 수치는 헤드리스
   폰트 렌더 기준이다. 다른 기기에서 같은 문구가 조금만 넓게 그려지면 경계가 위로
   움직이고, 그때는 낭비가 아니라 가림(더 나쁜 쪽)이 된다. 근본 해법은 이 숫자를
   손으로 적지 않는 것이다 — 바의 실제 높이를 읽어 토큰에 쓰면 두 방향이 다 닫힌다.
   app.js 레인이라 남겨 둔다.

   자에 대한 주의: .topbar 는 min-height: var(--topbar-height) 를 쓴다. 그래서
   .topbar 의 rect 로는 '토큰 - 높이' 가 구조적으로 0 이고 낭비를 못 잰다(내용이
   줄어도 상자가 토큰까지 부푼다). 낭비를 재려면 측정 중에만 min-height 를 0 으로
   눌러 자연 높이를 읽어야 한다. 가림 방향만 rect 로 유효하다.

   'observed' 이거나 속성이 없으면 이 규칙은 아예 안 켜지므로 기존 화면은 그대로다. */
@media (max-width: 439px) {
  body[data-position-source="estimated"] {
    --topbar-height: 128px;
  }
}

/* ---- 375 px 미만: 상단 바를 2줄로 되돌린다 ---------------------------
   ≤760 px 규칙에서 상단 바는 [브랜드][버튼] 한 줄 + [상태] 한 줄, 모두 96 px 이
   설계였다. 그런데 브랜드 238 px + 간격 8 + 버튼 96 = 342 px 라, 좌우 여백을 뺀
   가용 폭이 그보다 좁아지는 366 px 이하에서는 버튼이 세 번째 줄로 밀려 124 px 이
   됐다(임계 366↔372 px 실측). 360 px 폭은 안드로이드 단말 다수의 실제 폭이고,
   360×640 에서 지도가 세로의 5 %p 를 그냥 잃고 있었다.
   정보를 빼지 않고 한 줄에 되돌리기 위해 세 가지를 줄인다 — 장식용 브랜드 아이콘
   (aria-hidden), 워드마크 한 단계, 버튼 최소 폭. 버튼 높이 44 px 는 유지한다.
   임계는 스케일의 최소 브레이크포인트(375)를 쓴다. */
@media (max-width: 374px) {
  .topbar {
    padding-inline: var(--space-2);
  }

  /* 장식 아이콘을 빼서 워드마크에 32 px 를 넘긴다. 의미는 h1 이 전부 갖고 있다. */
  .brand-icon {
    display: none;
  }

  h1 {
    font-size: 14px;
    letter-spacing: 0.02em;
  }

  /* 워드마크가 줄어든 만큼 첫 줄 오른쪽에 빈자리가 생긴다. 동작 버튼은 관례대로
     바 오른쪽 끝에 붙인다(≥375 px 에서는 브랜드 폭이 거의 줄을 채워 차이가 없다). */
  button {
    min-width: 80px;
    padding: 0 var(--space-3);
    margin-left: auto;
  }
}

/* 하단 스택은 세로축을 먹는다. 세로 여유가 있거나, 측면 레일을 놓을 만큼
   가로가 넓지 않은 화면에서 쓴다. 두 조건은 아래 레일 규칙과 정확히 배타적이다. */
@media (max-width: 760px) and (min-height: 501px),
  (max-width: 559px) {
  /* 상단 바 높이가 줄바꿈에 따라 변하므로, 패널은 상단 기준이 아니라
     화면 하단에 스택으로 쌓아 상단 바와 겹치지 않게 한다.
     스택 전체가 뷰포트보다 길어질 수 있으므로(가로 모드·작은 화면) 스택 자신이
     스크롤한다. 스크롤하지 않으면 카드가 화면 밖으로 밀려나 닿을 수 없다.
     좌우 여백은 inset 대신 padding 으로 준다 — 스크롤 박스가 카드 그림자를
     자르지 않도록 하기 위한 것이고, 화면상 여백은 종전과 같다. */
  .panels {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: var(--z-panel);
    padding: var(--space-2);
    /* 지도가 주인공이다. 스택은 화면의 60 % 를 넘지 않고, 넘치는 부분은 스크롤한다.
       60 % 만으로는 짧은 화면을 지키지 못한다 — 400 px 높이에서 상단 바 96 +
       스택 240 을 빼면 지도에 36 px 만 남았다(9 %). 지도에 남길 최소 띠를
       상한에 직접 넣어, 화면이 짧을수록 스택이 먼저 양보하게 한다.
       아주 짧은 화면에서는 이 상한이 카드 머리글 두 개보다도 작아질 수 있으므로,
       접기 컨트롤 두 개(44+44)와 간격·여백이 들어가는 112 px 를 하한으로 둔다.
       그래서 띠는 두 구간으로 갈린다(상단 바 96 기준, 실측이 식과 일치):
         높이 ≥ 376 → 스택 = 높이 − 264, 띠 = 168
         높이 < 376 → 스택 = 112,        띠 = 높이 − 208 (360×300 에서 92)
       아래 구간에서 --map-band-min 은 듣지 않는다. 링 크기는 app.js 의
       MIN_RING_PX 클램프가 대신 지킨다. */
    max-height: max(112px, min(60dvh, calc(100dvh - var(--topbar-height) - var(--map-band-min))));
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* 지도 크롬은 기본이 우하단인데, 이 레이아웃에서는 하단 스택이 그 자리를 통째로
     덮는다 — 어트리뷰션 0/84 표본, 줌 버튼 0/144 로 화면에 아예 없었다(375×812 실측).
     스택 높이는 내용에 따라 변해 CSS 로 그만큼 띄울 수 없으므로 상단 바 아래로 올린다.
     타일 제공자 표기는 빠뜨릴 수 없는 항목이다. */
  .maplibregl-ctrl-bottom-right {
    top: var(--topbar-height);
    bottom: auto;
  }

  /* 줌 버튼은 이 레이아웃에서 내린다. 휴대폰 지도 앱의 관례이고(구글·애플 지도
     모바일에 +/− 버튼이 없다), 핀치·휠·지도 포커스 후 +/− 키가 그대로 남는다.
     세 버튼을 어트리뷰션과 함께 띠에 올려 본 변형에서는 링이 343 → 249 px(375×812),
     360×640 에서는 하한 120 px 까지 찌그러졌다. 덮여서 안 보이는 버튼을 남기면
     탭 순서에는 있고 화면에는 없는 컨트롤이 된다. */
  .maplibregl-ctrl-bottom-right .maplibregl-ctrl-group {
    display: none;
  }

  .panel {
    position: static;
    width: auto;
    flex: none; /* 스택이 넘칠 때 카드가 줄어들면 내용이 서로 겹친다 */
  }

  /* 스크롤 영역은 스택 하나뿐이다. 카드 본문이 따로 스크롤하면 중첩 스크롤이 되고,
     화면에 자리가 남아 있는데도 범례 마지막 줄이 잘린다(375x812 에서 실측). */
  .panel-body {
    max-height: none;
    overflow: visible;
    border-top: 0; /* 구분선을 고정 머리글 쪽으로 옮긴다(아래 규칙) */
  }

  /* 스택이 스크롤되면 카드 머리글이 먼저 밀려 나간다. 접기 컨트롤은 그 카드가
     화면에 있는 동안 항상 닿을 수 있어야 하므로 스택 상단에 고정한다. */
  .panel > summary {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--color-surface-solid);
  }

  /* hover 틴트는 반투명이라 그대로 쓰면 sticky 머리글 아래로 본문이 비친다.
     불투명 바탕은 남기고 틴트만 그 위에 얹는다(토큰은 그대로). */
  .panel > summary:hover {
    background-color: var(--color-surface-solid);
    background-image: linear-gradient(var(--color-primary-soft), var(--color-primary-soft));
  }

  /* 구분선을 고정 머리글에 붙여 둔다. 본문이 선 아래로 지나가는 것이 보여야
     스크롤 중이라는 것이 읽힌다. 선이 본문에 있으면 글줄이 머리글에 잘린 것처럼 보인다. */
  .panel[open] > summary {
    border-bottom: 1px solid var(--color-border);
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  }
}

/* ---- 짧은 화면(가로 모드): 카드를 측면 레일로 ------------------------
   하단 스택을 그대로 쓰면 모자란 세로축을 카드가 다시 나눠 갖는다. 740×360 에서
   상단 바 96 + 스택 216 을 빼면 지도에 52 px 만 남고, 500 m 링이 지름 24~35 px
   (568×320 에서는 8 px)로 찌그러졌다 — 링은 "보이지만" 읽을 수 없다.
   지도 앱의 가로 모드 관례대로 카드를 측면에 세워 남는 가로축에서 자리를 얻는다.
   레일은 가로를 300 px 쓰므로 그만한 여유가 있을 때만 쓴다 — 560 = 레일 300 +
   지도 최소 260. 그보다 좁으면 레일이 지도를 잡아먹으므로 하단 스택으로 돌려보낸다
   (360×400 에서 레일 50 px 대 스택 47 px, 412×480 에서 146 px 대 155 px 로 실익 없음).
   레일은 화면 폭의 절반도 넘지 않는다. MapLibre 줌·어트리뷰션 컨트롤은 우측이라
   왼쪽 레일과 겹치지 않는다. */
@media (max-height: 500px) and (min-width: 560px) {
  .panels {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    position: fixed;
    top: var(--topbar-height);
    bottom: 0;
    left: 0;
    width: min(var(--panel-width), 50vw);
    z-index: var(--z-panel);
    padding: var(--space-2);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* 어트리뷰션 바는 폭이 410 px 이라 왼쪽 레일 밑으로 들어간다(568×320 에서
     표본 16/48 이 레일에 가림). 가용 폭을 레일 바깥으로 제한해 그 안에서 접히게 한다.
     레일이 없는 폭(740 이상)에서는 한 줄 그대로라 값이 바뀌지 않는다. */
  .maplibregl-ctrl-bottom-right {
    max-width: calc(100% - min(var(--panel-width), 50vw));
  }

  .panel {
    position: static;
    width: auto;
    flex: none; /* 레일이 넘칠 때 카드가 줄어들면 내용이 서로 겹친다 */
  }

  /* 스크롤 영역은 레일 하나뿐이다(중첩 스크롤 금지). */
  .panel-body {
    max-height: none;
    overflow: visible;
    border-top: 0;
  }

  /* 레일을 스크롤해도 그 카드의 접기 컨트롤은 닿을 수 있어야 한다. */
  .panel > summary {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--color-surface-solid);
  }

  .panel > summary:hover {
    background-color: var(--color-surface-solid);
    background-image: linear-gradient(var(--color-primary-soft), var(--color-primary-soft));
  }

  .panel[open] > summary {
    border-bottom: 1px solid var(--color-border);
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 1ms !important;
    animation-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
}
