/* ★사진·인화물은 직각(2026-08-15, 동주님: "사진 프레임에 배치할때 모서리 90도로
   각지게 넣으라니까 라운드로 잘라서 넣지 말고"). 인쇄 합성은 이미 radius=0인데
   (§composer.js), **화면에서 사진이 프레임에 얹히는 자리**가 아직 둥글어 손님이
   보는 결과가 라운드였다. 인화물은 직각 종이다 — 미리보기가 종이와 달라 보이면
   그건 미리보기가 거짓말을 하는 것이다. 아래 네 곳을 한 번에 각지게 만든다. */
/* ============================================================
   LUAMI 키오스크 — "Boutique Film Studio"
   페이퍼 톤 + 잉크 블랙 + 코랄 포인트 · 필름/사진 모티프
   (테마 변수: --bg/--card/--text/--pink 는 원격 테마로 덮어쓰기 가능)
   화면 구조는 하루필름(Snapiece) LayoutProcess/Start/Order/촬영부스/인쇄 그대로 이식
   ============================================================ */
@font-face {
  font-family: 'Pretendard Variable';
  font-weight: 45 920; font-style: normal; font-display: swap;
  src: url('/assets/fonts/PretendardVariable.woff2') format('woff2-variations');
}

:root {
  --bg: #f1ebdd;          /* 따뜻한 페이퍼 */
  --card: #fbf7ee;        /* 카드/표면 */
  --text: #181410;        /* 잉크 블랙 */
  --pink: #df4b2e;        /* 포인트(코랄레드) — 테마 accent */
  /* 파생(테마 색에 자동 적응) */
  /* 접근성 긴급수정(2026-07-14) — 52%였던 믹스비율은 --bg 대비 약 3.53:1로 WCAG AA(4.5:1)
     미달(실측: sRGB 상대휘도 공식 기준). 62%로 올려 대비 약 4.82:1 확보(4.5:1에 여유 margin). */
  --ink-soft: color-mix(in srgb, var(--text) 62%, var(--bg));
  --line: color-mix(in srgb, var(--text) 16%, transparent);
  --line-strong: color-mix(in srgb, var(--text) 30%, transparent);
  --accent-soft: color-mix(in srgb, var(--pink) 14%, transparent);
  /* --pink 원색은 --bg 대비 약 3.41:1로 AA 미달 — 경고성 문구 등 실제 읽는 텍스트에는
     --pink 대신 이 변수를 쓴다(--text를 섞어 어둡게). 보더·배경·아이콘 등
     장식/상태표시 용도는 브랜드 컬러 유지를 위해 계속 --pink 사용(WCAG는 텍스트에만 적용).

     75% -> 55% (2026-08-09 감사). 이 변수의 존재 이유가 "읽히는 텍스트"인데,
     75%로는 **출고되는 프리셋 9종 중 2종에서 여전히 AA 미달**이었다. 전 프리셋을
     실제로 계산해서(각 테마의 primary/bg/text로 color-mix를 재현) 비율을 훑은 결과:
        핑크 75%  최저 3.73 (wood-cream)   미달
        핑크 65%  최저 4.18 (wood-cream)   미달
        핑크 60%  최저 4.46 (wood-cream)   미달   <- 아슬하게 못 넘는다
        핑크 55%  최저 4.74 (wood-cream)   전 프리셋 통과
     wood-cream(#c47a3c on #f5ead4)이 제약 조건이고, pastel-pink가 그 다음이다.
     브랜드 색을 최대한 남기는 값이 55%라 그것을 골랐다(더 낮추면 회색에 가까워진다).
     이 변수를 쓰는 모든 자리가 같이 좋아진다 — 한 곳씩 예외를 두는 것보다 낫다. */
  --pink-text: color-mix(in srgb, var(--pink) 55%, var(--text));
  /* 시각 기반 리디자인(2026-07-15) — 2차 포인트 컬러. --pink 자체를 재사용하는 대신 --text를
     섞어 한 단계 눌러(색상환 회전 대신 명도로 위계 구분 — color-mix만으로 hue-rotate와
     유사한 "다른 톤" 효과) 브랜드 포인트와 나란히 써도 구분되는 보조 강조색을 만든다.
     원시색 4개(--bg/--card/--text/--pink) 계약은 그대로, 파생만 추가. */
  --accent-2: color-mix(in srgb, var(--pink) 55%, var(--text) 45%);
  /* 대기화면 히어로 배경(2026-07-15, §buildIdleHeroMockup) — 원시색 중 --pink 소량 + --card +
     --bg만 섞은 은은한 방사형 그라디언트. 새 원색 도입 없이 "필름 프리뷰" 톤을 만든다. */
  --grad-hero: radial-gradient(circle at 50% 18%, color-mix(in srgb, var(--pink) 16%, var(--card)) 0%, var(--bg) 72%);
  --film: #14110c;
  /* 시맨틱 색상(2026-07-14) — style.css 곳곳에 흩어져 있던 하드코딩 성공/삭제/스티커핸들
     색상을 변수로 통일(브랜드 팔레트와 무관한 고정 의미색이라 --pink 파생이 아닌 별도 상수). */
  /* 시맨틱 색상 공유값 동기화(2026-07-15) — admin.css --ok-text/report·preselect.html
     .action과 이미 일치하는 #256b40(WCAG 5.65:1 감사필)으로 통일(기존 #2f7d4f는
     감사 기록 없이 추출된 값이었음 — git이력 확인, ff5c4cc). --card 배경 대비
     6.03:1로 오히려 개선(회귀 아님). */
  --success: #256b40;
  --danger-strong: #b8341d;
  --select-handle: #21b8d6;
  /* 선택/활성 상태 공통 "브랜드 시그니처" 하드 오프셋 그림자(2026-07-14) — .chip.on 등
     기존 selected 상태들이 쓰던 그림자를 크기 3단계로 추출. 대각 45˚ 오프셋 + --accent-soft. */
  --shadow-sig-sm: 4px 4px 0 var(--accent-soft);
  --shadow-sig-md: 6px 6px 0 var(--accent-soft);
  --shadow-sig-lg: 8px 8px 0 var(--accent-soft);
  /* 시각 기반 리디자인(2026-07-15) — 위 3단계(4/6/8px)에 이어지는 한 단계 더 큰 오프셋. */
  --shadow-sig-xl: 10px 10px 0 var(--accent-soft);
  /* "떠 있는 카드" 그림자(2026-07-15) — .print-frame canvas(구 28%)와 .print-preview-box
     (구 20%)에 각각 하드코딩돼 있던 "0 16px 50px color-mix(--text N%, transparent)" 중복
     패턴을 하나로 통합. 둘 중 더 진한 값(28% — 최종 인쇄 캔버스 쪽, 실제 렌더링 값 그대로
     추출한 것이지 새로 고른 값 아님)을 채택해 두 곳 모두에 적용(§DESIGN 검토 — 둘 다 "떠
     있는 카드" 용도라 통일이 더 일관적, 20%→28% 차이는 육안상 미미). */
  --shadow-float: 0 16px 50px color-mix(in srgb, var(--text) 28%, transparent);
  /* 시스템 메시지 전용 고정 팔레트(2026-07-09) — 점검잠금/카메라·프린터 보호/토스트/원격메시지.
     매장 테마(--bg/--text 등)를 상속하지 않는 고정값 — "이건 매장 화면이 아니라 시스템 안내"임을
     테마와 무관하게 항상 같은 모습으로 구분되게 하려는 의도. 화이트 앤 블랙, 장식 최소화. */
  --sys-bg: #ffffff;
  --sys-fg: #14110c;
  /* 비텍스트 대비(WCAG 1.4.11) 수정(2026-08-03, 라운드67) — 이 토큰이 쓰이는 자리는
     저장소 전체에서 **한 곳뿐**이다: 기기 등록 게이트의 비밀번호 입력칸 테두리
     (§remote.js showRegisterGate). 흰 배경 위 #e6e6e6은 실측 **1.25:1**이라, 그 칸이
     입력칸이라는 걸 알려주는 유일한 표시가 사실상 안 보였다(1.4.11은 3:1 요구).
     #8a8a8a = **3.45:1**(실측). 여백을 두되 더 어둡게 가지 않은 이유는 이 팔레트가
     "장식 최소화"를 의도한 시스템 화면이라 선이 눈에 띄는 요소가 되면 안 되기 때문이다.
     ★쓰이는 곳이 하나뿐이라 이 변경의 파급은 그 입력칸 하나로 닫혀 있다(전수확인). */
  --sys-line: #8a8a8a;
  --font: 'Pretendard Variable', Pretendard, -apple-system, 'Malgun Gothic', sans-serif;
  --mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
  /* 화면 세부조작 1단계 — 어드민 ui.layout.cornerRadiusScale이 remote.js applyUi()로 설정.
     기본 반경(px) x 배율. 2026-07-15부로 아래 --radius-1..9/--radius-pill 스케일 전체에
     적용(구체적 정의는 파일 하단 "디자인 토큰 정비" 블록 — --radius-card-base도 거기서
     하위호환 alias로 재정의됨). */
  --ui-radius-scale: 1;
  /* 품질상향(2026-07-10) — 접근성 큰글씨 모드. 어드민 ui.layout.fontScale이 remote.js
     applyUi()로 설정(1.0~1.25). 아래 전체 font-size 선언이 calc(값 * var(--ui-font-scale))로
     감싸져 있어 이 변수 하나로 화면 전체 글자 크기가 함께 커진다. */
  --ui-font-scale: 1;

  /* ============================================================
     디자인 토큰 정비(2026-07-15) — 통합 원칙: 기존 실제 렌더링 값은 보존, 흩어진
     매직넘버만 공유 변수로 묶는다(값 자체를 새로 고르지 않음). 아래 3개 스케일 모두
     "기존에 실제 쓰이던 값"에서 그대로 추출했고, 정확히 일치하는 곳만 치환했다 —
     비슷하지만 다른 값(예: 15px vs 15.5px)은 강제로 반올림하지 않고 그대로 둔다
     (점진 치환 — 시각 회귀 방지가 리디자인보다 우선).
     ============================================================ */
  /* 타이포그래피 스케일 — 실제 사용 중이던 clamp() 조합 중 대표값(2회 이상 중복되었거나
     역할이 뚜렷한 것)만 추출. 나머지 컴포넌트별 개별 clamp()는 그대로 유지(범위 밖). */
  --fs-display: clamp(calc(46px * var(--ui-font-scale)), calc(9vw * var(--ui-font-scale)), calc(110px * var(--ui-font-scale)));
  --fs-h1: clamp(calc(30px * var(--ui-font-scale)), calc(4.4vw * var(--ui-font-scale)), calc(44px * var(--ui-font-scale)));
  --fs-h2: clamp(calc(20px * var(--ui-font-scale)), calc(3vw * var(--ui-font-scale)), calc(34px * var(--ui-font-scale)));
  --fs-h3: clamp(calc(19px * var(--ui-font-scale)), calc(2.3vw * var(--ui-font-scale)), calc(28px * var(--ui-font-scale)));
  --fs-body: clamp(calc(15px * var(--ui-font-scale)), calc(1.8vw * var(--ui-font-scale)), calc(20px * var(--ui-font-scale)));
  --fs-caption: clamp(calc(13px * var(--ui-font-scale)), calc(1.6vw * var(--ui-font-scale)), calc(16px * var(--ui-font-scale)));
  /* 여백(padding/gap) 스케일 — 파일 전체 실측 빈도 상위 8종을 그대로 오름차순 번호매김
     (4/8배수로 억지 반올림하지 않음 — 6px·10px·14px 등 기존 값 그대로). */
  --space-1: 4px;
  --space-2: 6px;
  --space-3: 8px;
  --space-4: 10px;
  --space-5: 12px;
  --space-6: 14px;
  --space-7: 16px;
  --space-8: 20px;
  /* border-radius 스케일 — 흩어져 있던 하드코딩 값(1~14px + 알약형 999px) 전수를 오름차순
     번호매김. 전부 calc(var(--radius-N) * var(--ui-radius-scale))로 사용해, 기존엔 cam-holder
     한 곳에만 적용되던 원격 반경조절이 전체 화면에 실제로 먹히게 통일. */
  --radius-1: 1px;
  --radius-2: 2px;
  --radius-3: 3px;
  --radius-4: 4px;
  --radius-5: 5px;
  --radius-6: 6px;
  --radius-7: 8px;
  --radius-8: 10px;
  --radius-9: 14px;
  --radius-pill: 999px;
  --radius-card-base: var(--radius-4); /* 옛 이름 하위호환 alias(cam-holder 등이 참조) — 신규 코드는 --radius-4 사용 */
  /* 최소 터치타깃 하한(2026-07-31) — WCAG 2.5.5 / 국내 무인정보단말기 접근성 지침의 44px.
     낮은화면 모드에서 화면이 축소되면 렌더 크기가 그만큼 줄므로 배율의 역수로 키운다
     (0.55배 → CSS 80px → 화면 44px). --low-screen-scale은 배율 1일 때 app.js가 아예 제거해
     폴백 1이 되므로, 이 한 줄이 기본 배율(44px)과 축소 배율을 모두 커버한다.
     쓰는 곳 = 아래 터치타깃 하한 규칙 + 컴포넌트의 --touch-min-w/h max() 계산. */
  --touch-floor: calc(44px / var(--low-screen-scale, 1));
  /* 포인트색 위에 올라갈 글자색. remote.js applyTheme()이 매장 포인트색의 밝기를 보고
     덮어쓴다(§onPrimary). 여기 기본값이 #fff인 건 **의도적**이다 — 스크립트가 못 돌아도
     지금까지와 완전히 같은 화면이 나와야 하므로. */
  --on-pink: #fff;

  /* ── 모션 토큰(2026-08-06) ──────────────────────────────────────────────
     실측으로 시작한 항목이다: 렌더된 561개 요소의 transition을 세어보니 **전환 곡선이
     493곳 전부 브라우저 기본 `ease`**였고, 시간값은 .08/.12/.14/.16/.18이 근거 없이 섞여
     있었다. 특별한 순간(카운트다운·셔터·QR)은 @keyframes 24종으로 잘 연출돼 있는데,
     정작 손님이 한 세션에 수십 번 만지는 **일상 터치 반응만 기본값**이었다 — 만든 티는
     여기서 난다. 곡선과 시간을 토큰으로 묶어 한 손에서 관리한다.

     곡선을 이렇게 고른 이유:
     · --ease-tap  : 시작이 거의 수직이라 손가락이 닿는 즉시 반응이 "출발"한 것처럼 보인다.
                     같은 시간이라도 기본 ease보다 빠르게 느껴지는 이유가 이 앞부분이다.
     · --ease-out  : 움직임의 대부분을 앞에서 끝내고 길게 안착. 화면 전환·이동의 기본값.
     · --ease-spring: 끝에서 살짝 넘쳤다 돌아온다(오버슈트). **선택 확정에만** 쓴다 —
                     프레임·필터·스티커를 고르는 건 이 제품에서 제일 즐거운 동작이고,
                     그 순간에만 생기가 있어야 나머지가 산만해지지 않는다.

     시간값은 세 단계로만 줄였다. 100ms 이내는 "즉시"로 지각되므로 탭 확인은 90ms,
     상태 변화는 150ms, 실제로 자리를 옮기는 움직임은 240ms. */
  --ease-tap: cubic-bezier(.2, .9, .25, 1);
  --ease-out: cubic-bezier(.22, 1, .36, 1);
  --ease-spring: cubic-bezier(.34, 1.56, .64, 1);
  --dur-tap: 90ms;
  --dur-quick: 150ms;
  --dur-move: 240ms;
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* 폼 요소 글꼴 상속(2026-08-06) — <button>·<input> 계열은 글꼴을 상속하지 않고 OS 기본
   (윈도우에서 Arial)로 떨어진다. 그래서 style.css가 font-family를 지정해 준 버튼만 브랜드
   글꼴(Pretendard)이고, 지정을 빠뜨린 곳은 다른 글꼴로 렌더되고 있었다 — 실측으로 결제화면의
   수량 버튼(.pay-qty-btn)·결제수단 버튼(.pay-method-btn)·피부보정 단계 버튼이 Arial이었다.
   같은 화면에 글꼴 두 개가 섞이면 "정리가 안 된" 인상의 직접적 원인이 된다. 한 줄로 막는다.
   ※크기(font-size)는 일부러 상속시키지 않는다 — 기존에 크기가 지정된 곳의 비율이 한꺼번에
   바뀌는 변경이라, 화면을 눈으로 확인할 수 없는 지금 건드릴 값이 아니다. */
button, input, select, textarea { font-family: inherit; }
html, body {
  margin: 0; height: 100%; overflow: hidden;
  font-family: var(--font); background: var(--bg); color: var(--text);
  user-select: none; -webkit-font-smoothing: antialiased;
  /* 터치 하이라이트 사각형 제거(2026-08-09) — 누를 때마다 파란 상자가 번쩍이면 싸구려로 보인다.
     ★`-webkit-touch-callout`은 여기 **일부러 안 넣었다**. 그건 Safari/WebKit 전용이고
     Chromium(Edge)엔 구현이 없다 — 처음에 넣었다가 렌더해서 재보니 계산값이 빈 문자열이었다.
     넣어두면 "터치 길게누르기도 CSS로 막았다"는 **거짓 안심**만 남는다. Edge에서 길게누르기를
     실제로 막는 것은 contextmenu preventDefault 하나뿐이다(§public/js/kiosk-shell-guard.js).
     그쪽은 실측으로 확인했다(본문·이미지 모두 defaultPrevented=true). */
  -webkit-tap-highlight-color: transparent;
}
/* 사진·인화물 미리보기 — 손님이 자기 사진을 길게 눌러 저장·검색하려는 것이 가장 자연스러운
   충동이고, 그게 정확히 Edge 비주얼 서치로 가는 길이다. user-drag는 Chromium이 실제로
   지원한다(실측: 계산값 none) — 드래그해서 다른 앱에 떨구는 경로를 막는다. */
img, canvas, video {
  -webkit-user-drag: none;
  user-select: none;
}
/* 페이퍼 결(4px 도트 격자) — **완전 제거**(2026-08-15, 동주님 3차 지적).
   경과: ①"격자 그리드 없애줘, 커스텀에서는 빼는 게 좋겠다" → 커스텀 배경일 때만 껐다
        ②"그리드는 사라진 것 같은데 도트가 남아있는 것 같애"
        ③"프로그램 전체에 작고 회색인 도트들이 엄청 촘촘히 들어가있어 이거 없애달라는건데"
   ①에서 조건부로 끈 게 잘못이었다. 조건이 하나라도 어긋나는 경로(설정이 아직 안 내려온 순간,
   커스텀 배경이 없는 매장, 관리자 콘솔, 오버레이)에서는 그대로 보이고, 무엇보다 **소유자는
   이 무늬 자체를 원하지 않는다.** 조건을 더 붙이는 대신 규칙을 지운다.
   ★원격데스크톱에서 특히 심하게 보인다: 4px 주기·저대비 무늬는 RDP의 압축·리스케일과 만나
   모아레로 증폭된다. "내가 원격으로 봐서 그런가?"의 답은 "화면이 실제로 그리고 있었고,
   원격이 그걸 더 도드라지게 했다"이다.
   되살릴 일이 있으면 git log에서 이 블록을 찾으면 된다(radial-gradient 1px / 4px 타일 / multiply).
   .has-custom-bg 클래스 자체는 §remote.js가 계속 달아 준다 — 다른 규칙이 쓸 수 있게 남겨 둔다. */
#kiosk { position: relative; width: 100vw; height: 100vh; }

/* 낮은화면 모드 = "화면 아래보기"(2026-07-31, 라운드16 #1 — §index.html #low-screen-toggle
   주석에 법령 근거). 세로형 대형 디스플레이 상단에 손이 닿지 않는 휠체어 이용자·어린이를
   위해 화면 전체를 아래쪽으로 축소 배치한다.
   구현이 "#kiosk 하나에 transform" 인 이유:
   - 화면(.screen)마다 따로 손대면 12개 화면 레이아웃을 전부 다시 검증해야 하고, 새 화면이
     생길 때마다 누락된다. #kiosk는 모든 화면·모달·토스트의 공통 조상이라 한 번에 내려간다.
   - transform-origin:bottom center = 아래쪽 가장자리를 고정한 채 위쪽만 비워지는 축소 —
     "아래로 내린다"는 요구를 그대로 표현한다(translate로는 화면 밖으로 밀려나 못 만든다).
   - position:fixed 자손(#toast 등)은 transform이 걸린 조상이 컨테이닝 블록이 되므로 함께
     내려간다. 반대로 document.body에 직접 붙는 음성안내/직원호출 버튼(§voice-guide.js,
     §app.js initCallStaffBtn)은 #kiosk 밖이라 안 움직이지만, 원래 bottom:3vh(화면 최하단)
     이라 도달범위 안에 이미 있다 — 이 모드가 해결하려는 문제의 대상이 아니다.
   포인터 좌표계: 사진선택 화면의 슬롯 히트테스트·팬은 전부 getBoundingClientRect() 대비
   비율(§app.js selPanStart rectW/rectH)이라 배율에 영향받지 않는다. 유일한 예외는 두손가락
   핀치 확대(원시 px 델타 × 0.005)로, 축소된 만큼 감도가 같은 비율로 낮아진다(화면 속 사진도
   같이 작아지므로 체감상 어긋나지 않는다 — 실측 기록은 HANDOFF §12-237).
   기본값(--low-screen-scale:1)에서는 transform:scale(1)조차 걸지 않는다(합성 레이어를 새로
   만들지 않기 위함) — 클래스가 없으면 이 규칙 자체가 매칭되지 않는다. */
body.low-screen #kiosk {
  transform: scale(var(--low-screen-scale, 1));
  transform-origin: bottom center;
}
/* 터치타깃 보정 — 이게 없으면 "접근성 기능이 접근성을 깨는" 자기모순이 된다.
   실측(1080×1920 세로 뷰포트): 기본 배율에서 상품선택 화면의 조작요소는 전부 52px 이상이지만
   0.72배에서 처음으로 화면 홈버튼 37.4px·사전선택 바 40.3px가 44px 밑으로 떨어지고, 0.55배에선
   28.6px·30.8px까지 내려간다(접근성 패널 ♿ 버튼도 44→24.2px). 44px은 WCAG 2.5.5 / 국내
   무인정보단말기 접근성 지침이 요구하는 최소 터치타깃이라, 화면을 내려서 손이 닿게 만들어놓고
   정작 그 버튼이 눌리지 않는 상태가 된다 — 특히 이 모드를 되돌릴 ♿ 버튼 자신이 가장 작아진다.
   그래서 축소 배율의 역수만큼 CSS상 최소치를 키워 **실제 렌더 크기가 44px 이상으로 유지**되게
   한다(0.55배 → CSS 80px → 화면 44px). 큰 기본 버튼들은 이미 그보다 커서 이 규칙에 걸리지
   않으므로 레이아웃이 바뀌는 곳은 원래 최소크기였던 소수 요소뿐이다.
   .screen/.modal-overlay 안으로 한정 — 관리자 온스크린 에디터(§editmode.js)가 만드는 핸들 등
   손님 조작 대상이 아닌 UI까지 부풀리지 않기 위함. 모달은 .screen의 자손이 아니라 #kiosk
   직계 형제라(§index.html) 반드시 따로 적어야 한다 — 실측에서 .screen만 적었을 때 재인쇄
   수량 ±버튼(0.55배 22px)·확인 모달 버튼(36.3px)이 그대로 남는 것을 확인하고 추가.

   ★2026-07-31 라운드16 #4 — 이 규칙에서 `body.low-screen` 한정을 걷어내 **기본 배율에도**
   적용한다. 그전까지는 낮은화면 모드를 켰을 때만 44px이 보장돼, 기본 배율에서 44px 미만인
   조작요소 13개가 그대로 남아 있었다(실측: 촬영화면 피부보정 4개 33~44.1×29, 꾸미기 텍스트
   굵기 3개 56×34, 칩 검색입력 2개 h34, 미리보기 QR위치 2개 40~49.9×40, 재인쇄 수량 ± 2개
   40×40). "접근성 모드를 켜야만 접근성 기준을 만족한다"는 건 뒤집힌 상태였다 — 무인정보단말기
   접근성 의무는 기본 상태에 걸리는 것이고, 애초에 이 모드를 켤 수 없는 손님(휠체어가 아닌
   손떨림·저시력·어린이)에게는 보정이 도달하지 않았다.
   ★선택자를 두 벌로 나누지 않고 **한 벌만 유지**하는 이유: 기본용/낮은화면용 목록을 따로 두면
   한쪽에만 셀렉터가 추가되는 순간 조용히 어긋난다(§12-230 미러 상수 사고와 동일한 실패모드).
   `--low-screen-scale`은 배율 1일 때 JS가 아예 제거하므로(§app.js applyLowScreenScale)
   `var(…, 1)` 폴백이 그대로 44px이 되고, 축소 시에만 역수로 커진다 — 한 규칙이 두 상태를
   모두 만족한다.
   ★★이 규칙은 `min-width`를 쓰는데, 셀렉터 특이도가 (0,1,1)이라 **컴포넌트가 자기 규칙에
   적어둔 더 큰 min-width를 눌러버린다**(단일 클래스 = (0,1,0)). 구현 중 실측으로 잡았다:
   블랭킷을 그냥 켜니 사진선택 편집버튼 78→56.1px, 꾸미기 도구버튼 56→44px, 결제수단 버튼
   min-width 110→44px로 **오히려 작아졌다** — "최소 하한"이 "상한"으로 뒤집힌 것.
   그래서 하한을 `var(--touch-min-w, var(--touch-floor))`로 읽는다. 컴포넌트는 자기 규칙에서
   `--touch-min-w: max(<자기 디자인값>, var(--touch-floor))`만 선언하면 되고, 아무것도 선언
   안 한 대다수 요소는 폴백으로 44px 하한을 그대로 받는다. **하한은 여전히 이 한 곳**이고,
   컴포넌트는 자기 크기만 소유한다(두 목록이 어긋날 자리가 없다).
   ※`--touch-min-w`는 상속되지만 문제되지 않는다 — 이 하한이 걸리는 대상은 button/input이고
   HTML상 그 안에 다른 button/input을 넣을 수 없어(대화형 요소 중첩 금지) 상속값을 잘못
   물려받을 자손이 존재하지 않는다. 컨테이너에는 절대 선언하지 말 것.
   ※개별 컴포넌트의 시각 크기(.pay-qty-btn width:40px 등)는 일부러 그대로 뒀다. 디자인 의도는
   그 자리에 남기고 접근성 하한만 여기서 강제한다(min-width가 width를 이기므로 결과는 44px). */
.screen button,
.screen [role="button"],
.screen input:not([type="range"]):not([type="checkbox"]):not([type="radio"]),
.modal-overlay button,
.modal-overlay input:not([type="range"]):not([type="checkbox"]):not([type="radio"]),
#a11y-panel-toggle,
#a11y-panel button {
  min-width: var(--touch-min-w, var(--touch-floor));
  min-height: var(--touch-min-h, var(--touch-floor));
}

.screen {
  position: absolute; inset: 0; display: none; flex-direction: column;
  padding: 5vh 6vw; animation: fade .4s ease;
  /* 편집모드 클릭시 확대 버그(2026-07-11 지적) — 뷰포트 meta(user-scalable=no)만으론 최신
     브라우저(iOS Safari 등)가 접근성 이유로 이를 무시함(웹 검색으로 재확인된 표준 이슈).
     manipulation은 더블탭 확대만 막고 스크롤·핀치줌은 그대로 허용 — 고객 화면에도 안전. */
  touch-action: manipulation;
}
.screen.active { display: flex; }
.screen.has-process { padding: 0; }
/* 대기화면은 전면(2026-08-06) — 이 화면은 손님이 부스 밖에서 보는 **간판**이다. 액자처럼
   테두리와 여백을 두르면 그만큼 간판이 작아진다. 레퍼런스(포토이즘)도 대기화면만은 화면
   전체를 아트워크로 쓴다. 진행화면(.has-process)은 정보 화면이라 여백을 그대로 둔다. */
#screen-idle { padding: 0; }
#screen-idle .idle { border: 0; border-radius: 0; }
@keyframes fade { from { opacity: 0; transform: translateY(10px);} to {opacity:1; transform:none;} }

/* ============================================================
   번들 배경 텍스처(2026-08-14, #84) — 화면 배경으로 **`/bg/` 아래 파일**을 지정한 경우에만.
   (§remote.js applyStepBackgrounds가 `#screen-<id>`의 인라인 style에 url()을 꽂는다 —
    그 인라인 값 안에 `/bg/`가 들어 있을 때만 이 규칙이 걸린다. 어드민에서 **업로드한**
    이미지는 전부 `/frameimg/…`라 여기 절대 안 걸린다 = 기존 매장 화면 무회귀.
    ★선택자를 `url(/bg/`로 쓰면 안 된다 — cssUrlRaw()는 따옴표 없이 넣지만 CSSOM에 대입된
      값은 브라우저가 다시 직렬화하면서 **따옴표를 붙인다**(실측: style 속성이
      `background-image: url("/bg/inu-silk.webp");`). 처음에 그렇게 썼다가 규칙이 한 번도
      안 걸렸고, 대기화면 노출이 계속 0.00%로 측정돼서 잡았다.)

   왜 색을 곱하나(background-blend-mode: multiply)
   ---------------------------------------------------------------------------
   배경 이미지를 그냥 깔면 그 이미지가 테마를 **이긴다.** 그래서 손님이 접근성 패널에서
   **고대비 모드**를 켜도(§app.js HIGH_CONTRAST_THEME — --bg를 #0a0a0a로 덮어씀) 화면은
   여전히 밝은 이미지 그대로이고, 글자만 흰색(#f5f5f5)으로 바뀌어 **저시력 손님을 위해 켠
   기능이 그 손님의 글자를 지운다.** 실제로 인천대 배경(거의 흰색 실크)에서 이 조합은
   1.1:1 수준이 된다.
   그래서 `/bg/` 자산은 "그림"이 아니라 **테마 배경색을 변조하는 텍스처**로 취급한다:
     실제 배경색 = var(--bg) x 텍스처(곱하기)
   흰색(1.0)인 부분은 테마색 그대로, 어두운 부분만 그만큼 눌린다. 곱하기는 **밝게 만들지
   못하므로** 어두운 테마·고대비 모드에서 배경이 밝아지는 일이 원리적으로 불가능하다
   (밝은 테마에선 그림 그대로 보이고, 고대비를 켜면 같은 텍스처가 검정 위 결로 바뀐다).
   ★자산 쪽 전제: 텍스처의 어두운 정도가 곧 대비 손실이다. 인천대 실크는 원본의 가장 어두운
   주름(24px 블록 평균 sRGB 0.535)을 그대로 쓰면 보조문구(--ink-soft)가 1.51:1까지 떨어져서,
   저주파 그림자를 0.10배로 눌러 넣었다(public/bg/inu-silk.webp, 최악 자리 4.84:1로 실측).
   규격·재현 절차는 §docs/SCREEN-BACKGROUND-GUIDE.md.
   ============================================================ */
.screen[style*="/bg/"] {
  background-color: var(--bg);
  background-blend-mode: multiply;
}
/* 대기화면만 예외 처리가 하나 더 필요하다 — 이 화면은 "탭하여 시작" 버튼(.idle)이 화면
   전체를 **불투명하게** 덮고(background: 그라디언트 + var(--card)), 그 위에 히어로 목업
   (.idle-hero, var(--grad-hero))이 한 겹 더 깔린다. 그래서 지금까지 대기화면 배경은 올려도
   노출 0.0%였다(§docs/SCREEN-DESIGN-GUIDE.md §2의 ★). 번들 텍스처를 지정한 경우에만 그 두
   겹을 비워 배경이 실제로 보이게 한다 — 업로드 배경(/frameimg/)의 기존 동작은 그대로 둔다
   (아무 매장에나 적용하면 지금 대기화면 디자인이 통째로 바뀌므로). */
#screen-idle[style*="/bg/"] .idle { background: none; }
#screen-idle[style*="/bg/"] .idle-hero { display: none; }

/* ----- 공통 타이포 ----- */
.kicker {
  font-family: var(--mono); font-size: clamp(calc(11px * var(--ui-font-scale)), calc(1.3vw * var(--ui-font-scale)), calc(14px * var(--ui-font-scale))); letter-spacing: .22em;
  text-transform: uppercase; color: var(--ink-soft); display: inline-flex; align-items: center; gap: 9px;
}
.kicker .kdot { width: 7px; height: 7px; border-radius: 50%; background: var(--pink); }

/* ----- 버튼 ----- */
.primary-btn {
  background: var(--text); color: var(--bg); border: 0; border-radius: calc(var(--radius-9) * var(--ui-radius-scale));
  padding: 18px 42px; font-family: var(--font); font-size: clamp(calc(18px * var(--ui-font-scale)), calc(2.3vw * var(--ui-font-scale)), calc(25px * var(--ui-font-scale))); font-weight: 700;
  /* 품질상향(2026-07-10) — 탭 반응 속도. 키오스크 UX 리서치 기준(탭 피드백은 100ms 이내
     시작) 참고해 transform만 140ms→80ms로(background는 호버 전환용이라 그대로 유지). */
  letter-spacing: -.01em; cursor: pointer; transition: transform var(--dur-tap) var(--ease-tap), background var(--dur-quick) var(--ease-out);
}
.primary-btn.big { padding: 22px 56px; font-size: clamp(calc(20px * var(--ui-font-scale)), calc(2.8vw * var(--ui-font-scale)), calc(30px * var(--ui-font-scale))); }
.primary-btn:active { transform: translateY(2px); }
.primary-btn:hover { background: var(--pink); }
.primary-btn:disabled { opacity: .4; pointer-events: none; }
.ghost-btn {
  background: transparent; color: var(--text); border: 1.5px solid var(--line-strong);
  border-radius: calc(var(--radius-9) * var(--ui-radius-scale)); padding: var(--space-6) 30px; font-family: var(--font);
  font-size: var(--fs-body); font-weight: 600; cursor: pointer; transition: border-color var(--dur-quick) var(--ease-out), transform var(--dur-tap) var(--ease-tap);
}
.ghost-btn:hover { border-color: var(--text); }
.ghost-btn:active { transform: translateY(2px); }
.ghost-btn:disabled { opacity: .5; pointer-events: none; }
.ghost-btn.sm, .primary-btn.sm { padding: var(--space-4) 22px; font-size: calc(15px * var(--ui-font-scale)); }

/* ============================================================
   전역 LayoutProcess 크롬 (하루필름 LayoutProcess.vue 그대로 —
   좌상단 Home / 중앙 타이틀 / 우상단 Timer / box-container / 하단 BACK·NEXT)
   ============================================================ */
.lp-topbar {
  display: flex; align-items: center; justify-content: space-between;
  /* 좌우 **대칭**으로 112px씩 (2026-08-07 2차 수정).
     우측만 비웠더니 가운데 타이틀이 화면 중심에서 정확히 56px(=112/2) 왼쪽으로 밀렸다 —
     실측: 1080x1920 타이틀 중심 484 vs 화면 중심 540, 800x1280 344 vs 400, 1280x800 584 vs 640.
     세 규격 모두 정확히 -56이라 우연이 아니라 이 패딩이 원인임이 확정됐다.
     좌측에도 같은 양을 비우면 중앙정렬이 복원된다(좌상단 홈 버튼은 52px이라 112px 안에 들어간다). */
  padding: 3vh calc(4vw + 112px) 2vh calc(4vw + 112px); flex-shrink: 0; gap: var(--space-7);
}
.lp-home {
  width: 52px; height: 52px; border-radius: 50%; border: 1.5px solid var(--line);
  background: var(--card); color: var(--text); display: flex; align-items: center; justify-content: center;
  cursor: pointer; transition: border-color var(--dur-quick) var(--ease-out), transform var(--dur-tap) var(--ease-tap); flex-shrink: 0; padding: 0;
  font-family: var(--font);
}
.lp-home::before {
  content: ""; width: 22px; height: 20px;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 22'%3E%3Cpath d='M12 2 2 10v10h6v-6h8v6h6V10z' fill='black'/%3E%3C/svg%3E") center/contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 22'%3E%3Cpath d='M12 2 2 10v10h6v-6h8v6h6V10z' fill='black'/%3E%3C/svg%3E") center/contain no-repeat;
  background: currentColor;
}
.lp-home:hover { border-color: var(--text); }
.lp-home:active { transform: translateY(2px); }

.lp-title { flex: 1; text-align: center; min-width: 0; }
.lp-title .kicker { justify-content: center; }
.lp-title h2 { font-size: var(--fs-h2); margin: 6px 0 0; font-weight: 800; letter-spacing: -.02em; }

/* 남은 시간 = 우상단 거대 숫자(2026-08-06, 포토이즘 키오스크 화면분해 반영).
   레퍼런스는 모든 화면에서 같은 자리에 라벨도 단위도 없는 큰 숫자 하나만 둔다(57·27·73).
   숫자가 크면 읽지 않아도 "많다/적다"가 주변시로 들어오고, 작으면 오히려 눈으로 찾아 읽게 되어
   그 행위 자체가 압박이 된다. 서서 팔 길이(50~70cm)에서 보는 화면이라 22px는 애초에 안 읽혔다. */
.lp-timer { min-width: 52px; text-align: right; flex-shrink: 0; }
.lp-timer .step-timer {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-size: clamp(calc(30px * var(--ui-font-scale)), calc(4.6vh * var(--ui-font-scale)), calc(58px * var(--ui-font-scale)));
  font-weight: 800; line-height: 1; letter-spacing: -.02em;
  /* ★.55 → .85 (2026-08-09, 인천대 실외 팝업 부스 대비). 이 숫자는 "손님에게 남은 시간"인데
     실측 대비가 매장 테마에 따라 갈렸다:
         출고 기본(어두운 테마 #100E0C/#F5F0E7)   .55 → 5.61:1  (AA 통과, AAA 미달)
         시드 매장(밝은 테마)                      .55 → 3.85:1  (★AA 미달)
     같은 CSS가 매장마다 다른 대비를 내는 이유는 opacity가 **배경과 섞이는** 값이라 배경
     밝기에 따라 결과가 달라지기 때문이다 — 색을 고정하지 않고 투명도로 약화시킨 대가다.
     실외 햇빛에서는 AA(4.5)로 부족하고 AAA(7) 이상이 필요하다. .85면 출고 테마에서
     12.30:1(카드 위 11.15), 밝은 테마에서도 AAA 위로 올라간다.
     ★약화 자체는 유지한다 — `.urgent`(opacity 1 + 강조색)와의 대비가 이 디자인의 핵심이라
     아예 1로 올리면 "임박했다"는 신호가 사라진다(§아래 .urgent 주석). .85는 여전히 눈에
     띄게 약하다. 고대비 모드는 CSS 변수 교체 방식이라 클래스로 따로 잡을 수 없어
     기본값 자체를 올리는 것이 유일한 방법이다. */
  color: var(--text); opacity: .85; margin-left: 0;
  transition: color .25s var(--ease-out), opacity .25s var(--ease-out);
}
/* 임박할 때만 강조색으로 — 상시 강조하면 진짜 임박한 순간에 아무 차이도 못 만든다. */
.lp-timer .step-timer.urgent { color: var(--pink-text); opacity: 1; }
/* 조작 중 유예(§app.js HOLD_IDLE_MS) — 멈춰 있는 게 고장이 아니라 "기다려주는 중"임을
   조용히 알린다. 숫자를 흔들거나 문구를 띄우면 그게 또 방해가 되므로 밝기만 낮춘다. */
.lp-timer .step-timer.held { opacity: .28; }
@media (prefers-reduced-motion: reduce) { .lp-timer .step-timer { transition: none; } }
.lp-timer[hidden] { visibility: hidden; display: block; } /* 자리는 유지해 타이틀 중앙정렬 안 흔들리게 */

.lp-body { flex: 1; min-height: 0; padding: 0 4vw 2vh; display: flex; flex-direction: column; }
.box-container--outline {
  border: 1.5px solid var(--line); border-radius: calc(var(--radius-8) * var(--ui-radius-scale)); padding: 3vh 3vw;
  flex: 1; min-height: 0; display: flex; flex-direction: column;
}

.lp-footer {
  display: flex; justify-content: space-between; align-items: center;
  padding: 2vh 4vw 3.5vh; flex-shrink: 0; gap: var(--space-8);
}
.lp-footer .lp-back { order: 1; }
.lp-footer .lp-next { order: 2; margin-left: auto; }

/* ============================================================
   1) 대기 (하루필름 Start Variant A — 단일 풀스크린 TAP TO START)
   ============================================================ */
.idle {
  all: unset; box-sizing: border-box;
  /* z-index:0(2026-07-15 추가) — position:relative만으로는 새 스태킹 컨텍스트가 생기지 않아
     아래 #idle-hero(음수 z-index)가 이 박스 범위를 벗어나 body 배경 뒤로 새 나갈 위험이 있음.
     명시적으로 컨텍스트를 고정해 idle-hero/idle-inner/attract-loop/corner의 상대순서를 이
     버튼 안에서만 완결시킨다(기존 세 레이어의 실제 렌더 순서는 전혀 안 바뀜 — 아래 각주 참고). */
  flex: 1; position: relative; z-index: 0; display: flex; align-items: center; justify-content: center;
  cursor: pointer; background:
    linear-gradient(color-mix(in srgb, var(--text) 4%, transparent), transparent 40%),
    var(--card);
  border: 1px solid var(--line); border-radius: calc(var(--radius-4) * var(--ui-radius-scale)); width: 100%; height: 100%;
}
/* 대기화면 히어로 목업(2026-07-15 시각기반 리디자인) — buildIdleHeroMockup()(app.js)이 채우는
   배경 레이어. 음수 z-index로 "비위치 요소는 항상 위치요소보다 아래" 규칙보다 한 단계 더
   아래에 고정 — idle-inner(비위치)·attract-loop(위치, auto)·corner(위치, z:2) 중 어느 것도
   건드리지 않고 그 전부보다 아래에 깐다(§위 .idle z-index:0 각주). 3단 폴백의 중간 단계:
   attract-loop가 실사진으로 켜지면 그게 이 위를 자연히 덮고(둘 다 배경층), 꺼져있거나
   images가 비면(기본값) 이 목업만 보인다 — "켰는데 아무것도 안 나온다"는 빈 상태 제거.
   pointer-events:none 고정(§attract-loop와 동일 원칙) — #idle 버튼 클릭을 절대 가로채지 않음. */
.idle-hero {
  position: absolute; inset: 0; z-index: -1; overflow: hidden; pointer-events: none;
  border-radius: inherit; background: var(--grad-hero);
}
/* 필름 그레인 제거(2026-08-15) — body::before의 페이퍼 결과 **같은 기법·같은 이유**로 지운다.
   소유자가 원한 것은 "커스텀 배경일 때만 끄기"가 아니라 이 도트 무늬 자체가 없는 화면이다.
   3px 주기 도트라 body 쪽(4px)보다 더 촘촘해 원격데스크톱에서 특히 거슬린다. */
/* 비네트(요구사항) — 가장자리를 은은히 눌러 "필름 프리뷰"처럼 보이게. */
.idle-hero::after {
  content: ""; position: absolute; inset: -10%;
  background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--text) 26%, transparent) 100%);
}
.idle-hero-slide {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  opacity: 0; transition: opacity 900ms ease;
}
.idle-hero-slide.is-active { opacity: 1; }
/* 상품 카드 목업(.layout-mockup/.mockup-cell, §style.css 상품선택 섹션)을 히어로 크기로
   확대 재사용 — id 선택자로 특이도를 고정해 파일 내 선언 순서와 무관하게 항상 이긴다. */
/* 16:9 콜라주(2026-08-06) — §app.js buildIdleHeroMockup 주석 참고.
   5장을 가로로 늘어놓고 통째로 살짝 기울여 확대해, 양끝이 화면 밖으로 나가게 한다.
   가장자리가 잘려야 "여기서 끝"이 아니라 "계속 이어진다"로 읽히고, 그래야 좌우 여백이
   사라진다. rotate는 -3.5도 — 더 크면 장식으로 튀고, 0이면 정렬된 카탈로그처럼 보인다. */
.idle-hero-collage {
  display: flex; align-items: center; gap: clamp(14px, 2.2vw, 34px);
  transform: rotate(-3.5deg) scale(1.28); transform-origin: center;
  will-change: transform;
}
.idle-hero-card { flex: 0 0 auto; display: block; }
/* 가운데로 갈수록 살짝 크게 — 균일하면 벽지가 되고, 리듬이 있어야 사진 더미로 읽힌다.
   중앙(k=2)은 브랜드 블록 뒤로 들어가므로 오히려 조금 낮춰 텍스트 가독을 우선한다. */
.idle-hero-card[style*="--k:0"] .layout-mockup,
.idle-hero-card[style*="--k:4"] .layout-mockup,
.idle-hero-card[style*="--k:6"] .layout-mockup { height: clamp(200px, 42vh, 340px); opacity: .78; }
.idle-hero-card[style*="--k:1"] .layout-mockup,
.idle-hero-card[style*="--k:3"] .layout-mockup,
.idle-hero-card[style*="--k:5"] .layout-mockup { height: clamp(240px, 54vh, 430px); opacity: .92; }
.idle-hero-card[style*="--k:2"] .layout-mockup { height: clamp(220px, 48vh, 380px); opacity: .7; }
#idle-hero .layout-mockup {
  height: clamp(220px, 48vh, 400px); width: auto; max-width: none;
  background: var(--card); border-color: var(--line);
  box-shadow: var(--shadow-float);
}
/* 중앙 스크림 — 브랜드 이름·태그라인·CTA가 콜라주 위에 놓이므로 대비를 확보해야 한다.
   유리 반사와 매장 조명까지 감안하면 배경 위 글자는 반드시 바탕을 깔아야 읽힌다. */
.idle-hero::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(58% 62% at 50% 46%,
    color-mix(in srgb, var(--card) 90%, transparent) 0%,
    color-mix(in srgb, var(--card) 68%, transparent) 46%,
    transparent 78%);
}
@media (prefers-reduced-motion: reduce) { .idle-hero-collage { transform: rotate(-3.5deg) scale(1.28); } }
#idle-hero .mockup-cell {
  background: color-mix(in srgb, var(--pink) 18%, var(--card)); opacity: 1;
  display: flex; align-items: flex-end; justify-content: center; overflow: hidden;
}
/* 인물 실루엣(요구사항) — 원+타원 조합, currentColor 상속(테마 accent-2 자동 반영),
   성별·인종·브랜드 특정 없는 추상 아이콘이라 저작권·초상권 걱정 없음. */
.idle-hero-person { width: 62%; height: 96%; display: block; color: var(--accent-2); opacity: .55; }
.idle-inner { text-align: center; display: flex; flex-direction: column; align-items: center; gap: var(--space-6); padding: 4vh; }
/* 사진 인화 코너 */
.corner { position: absolute; width: 38px; height: 38px; border: 2.5px solid var(--text); z-index: 2; }
.corner.tl { top: 26px; left: 26px; border-right: 0; border-bottom: 0; }
.corner.tr { top: 26px; right: 26px; border-left: 0; border-bottom: 0; }
.corner.bl { bottom: 26px; left: 26px; border-right: 0; border-top: 0; }
.corner.br { bottom: 26px; right: 26px; border-left: 0; border-top: 0; }
.idle-cam { color: var(--text); width: clamp(64px, 9vw, 104px); }
.idle-cam svg { width: 100%; height: auto; display: block; }
.idle h1 {
  font-size: var(--fs-display); margin: 4px 0 0; font-weight: 900;
  letter-spacing: -.03em; line-height: .95;
}
.idle p { font-size: clamp(calc(17px * var(--ui-font-scale)), calc(2.6vw * var(--ui-font-scale)), calc(30px * var(--ui-font-scale))); color: var(--ink-soft); margin: 0; font-weight: 500; }
/* 필름 스트립 모티프 */
.filmstrip {
  display: flex; gap: var(--space-2); padding: 7px; background: var(--film); border-radius: calc(var(--radius-3) * var(--ui-radius-scale)); margin-top: 12px;
  position: relative;
}
.filmstrip::before, .filmstrip::after {
  content: ""; position: absolute; left: 7px; right: 7px; height: 4px;
  background-image: linear-gradient(90deg, var(--bg) 0 6px, transparent 6px 12px);
  background-size: 12px 4px;
}
.filmstrip::before { top: 1.5px; } .filmstrip::after { bottom: 1.5px; }
.filmstrip i { width: clamp(26px,4vw,40px); height: clamp(26px,4vw,40px); background: var(--card); border-radius: calc(var(--radius-1) * var(--ui-radius-scale)); }
.filmstrip i:nth-child(2){ background: color-mix(in srgb, var(--pink) 80%, white); }

/* 단일 CTA 오버레이(하루필름 TAP TO START, blink-text 그대로 — 스케일펄스로 재현) */
.idle-overlay-cta {
  position: absolute; left: 0; right: 0; bottom: 8vh;
  display: flex; justify-content: center; pointer-events: none;
}
.idle-blink {
  font-family: var(--font); font-weight: 800; letter-spacing: .01em;
  font-size: clamp(calc(18px * var(--ui-font-scale)), calc(2.6vw * var(--ui-font-scale)), calc(28px * var(--ui-font-scale))); color: var(--on-pink);
  background: var(--pink); border-radius: calc(var(--radius-pill) * var(--ui-radius-scale)); padding: var(--space-7) 40px;
  animation: idle-cue 2.4s ease-in-out infinite;
  box-shadow: 0 10px 30px color-mix(in srgb, var(--pink) 40%, transparent);
}
@keyframes idle-cue { 0%,100%{transform:scale(1)} 50%{transform:scale(1.04)} }

/* 뱃지 스트립(2026-07-15 시각기반 리디자인) — "프레임 N종 · 필터 N종 · 스티커 N종",
   buildIdleBadges()(app.js)가 CFG.FRAMES/FILTERS/STICKERS 실제 길이로 매번 계산해 채운다
   (하드코딩 숫자 없음 — 매장 상품구성이 달라도 항상 정확). idle-overlay-cta(bottom:8vh)보다
   더 아래(화면 맨 밑)에 둬 겹치지 않게 하고, z-index:1(양수)로 attract-loop/idle-overlay-cta
   (둘 다 auto/0단계)보다 항상 위에 고정 — 슬라이드쇼가 실사진으로 켜져 있어도 상품구성
   안내는 계속 읽힘. pointer-events:none 고정(§attract-loop와 동일 원칙). */
.idle-badges {
  position: absolute; left: 0; right: 0; bottom: 2.4vh; z-index: 1;
  display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3);
  pointer-events: none;
}
.idle-badge {
  font-family: var(--mono); font-size: calc(13px * var(--ui-font-scale)); font-weight: 700;
  letter-spacing: .04em; color: var(--ink-soft); background: var(--card);
  border: 1px solid var(--line); border-radius: calc(var(--radius-pill) * var(--ui-radius-scale));
  padding: 4px var(--space-5); box-shadow: var(--shadow-sig-sm);
}

/* 대기화면 시즌 프레임 배너(2026-07-26, 라운드6 finding 5) — idle-overlay-cta(bottom:8vh,
   "TAP TO START")와 화면 반대쪽 끝(top)에 둬 시각적으로 분리. idle-badges와 같은 톤(카드/라인)
   재사용, z-index:1로 attract-loop보다 위(실사진 슬라이드쇼가 켜져 있어도 계속 읽힘).
   pointer-events:none 고정(§attract-loop·idle-badges와 동일 원칙 — 탭하면 항상 #idle
   버튼 자신이 받아 정상 플로우로 진입). */
.idle-season-banner {
  position: absolute; left: 0; right: 0; top: 2.4vh; z-index: 1;
  display: flex; justify-content: center; pointer-events: none;
}
.idle-season-banner-pill {
  display: inline-flex; align-items: center; gap: var(--space-3);
  font-size: calc(12px * var(--ui-font-scale)); font-weight: 700; color: var(--ink-soft);
  background: var(--card); border: 1px solid var(--line);
  border-radius: calc(var(--radius-pill) * var(--ui-radius-scale));
  padding: var(--space-3) var(--space-5); box-shadow: var(--shadow-sig-sm);
  max-width: 84vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.idle-season-banner-dday {
  font-family: var(--mono); font-weight: 800; color: var(--pink-text);
  background: color-mix(in srgb, var(--pink) 14%, transparent);
  border-radius: calc(var(--radius-pill) * var(--ui-radius-scale)); padding: 1px 8px; flex: none;
}

/* 대기화면 단계 미리보기(2026-07-26, 라운드9 finding 3) — idle-overlay-cta(bottom:8vh,
   "TAP TO START")보다 위(bottom:20vh)에 둬 그 CTA 알약과 안 겹치게. 실측(getBoundingClientRect)
   결과 16vh는 CTA 알약의 실제 패딩된 높이 때문에 약 8px 겹쳤음 — 20vh로 여유를 늘림.
   idle-badges와 같은 카드/라인 톤 재사용, pointer-events:none 고정(다른 대기화면 레이어와
   동일 원칙). */
.idle-step-preview {
  position: absolute; left: 0; right: 0; bottom: 20vh; z-index: 1;
  display: flex; justify-content: center; pointer-events: none;
}
.idle-step-preview-pill {
  display: inline-flex; align-items: center; gap: var(--space-2);
  font-size: calc(12px * var(--ui-font-scale)); font-weight: 700; color: var(--ink-soft);
  background: var(--card); border: 1px solid var(--line);
  border-radius: calc(var(--radius-pill) * var(--ui-radius-scale));
  padding: var(--space-3) var(--space-5); box-shadow: var(--shadow-sig-sm);
  max-width: 90vw; white-space: nowrap;
}
.idle-step-preview-arrow { color: var(--line); font-weight: 400; }

/* 어트랙트 루프(2026-07-15 신설) — 대기화면 홍보 이미지 슬라이드쇼. .idle은 position:relative라
   이 레이어(position:absolute)가 idle-inner(비위치 요소 — 스태킹 규칙상 위치 지정 요소보다 항상
   아래에 그려짐)를 자연히 덮는다. idle-overlay-cta보다 DOM에서 앞에 둬(§index.html) "터치하여
   시작" 문구가 슬라이드 위에도 항상 보이게 함. pointer-events:none 고정 — idle-overlay-cta와
   동일한 원칙으로, 이 레이어가 어떤 상태에서도 #idle 버튼의 클릭을 가로채지 않는다(필수 요구사항,
   §app.js startAttractLoop 주석 참고). 전환은 setTimeout+CSS opacity transition만 사용
   (requestAnimationFrame 남발 없이 가볍게 — 무인 대기화면이 대부분인 시간대의 CPU/배터리 절약). */
.attract-loop {
  position: absolute; inset: 0; overflow: hidden; pointer-events: none;
  border-radius: inherit;
}
.attract-loop img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  opacity: 0; transition: opacity 900ms ease;
}
.attract-loop img.is-active { opacity: 1; }
/* 관광객 대상 영어 UI 토글(2026-07-15) — features.i18n 켜진 매장만 노출(§app.js/i18n.js).
   #idle(풀스크린 탭투스타트 버튼) 밖의 형제 요소로 대기화면 패딩 여백(5vh/6vw) 안쪽,
   .idle 박스가 시작되기 전 자리에 위치해 탭투스타트 영역과 절대 겹치지 않는다. */
/* 접근성 위젯 단일 패널 통합(2026-07-28, 라운드14 finding7) — 아래 3개 위젯(.lang-toggle/
   .font-size-toggle/.contrast-toggle) 각자의 독립 position:absolute 규칙은 그대로 두고
   (patch 규모 최소화 — 다른 화면에서 재사용될 가능성을 배제할 수 없어 규칙 자체는 안 건드림),
   .a11y-panel 안에 실제로 담길 때만 아래 override로 무효화해 패널의 세로 스택 레이아웃을
   따르게 한다. */
.a11y-panel-toggle {
  /* right에 56px을 더해 음성안내 버튼(우측 끝) 왼쪽에 나란히 선다 — 2026-08-07에
     보조버튼을 상단 우측으로 모으면서 이 버튼과 정확히 같은 자리가 됐다(실측 44x44 완전겹침,
     ♿가 🔊에 완전히 가려져 접근성 패널을 열 수 없었다). §.voice-guide-btn 주석. */
  position: absolute; top: 3vh; right: calc(4vw + 56px); z-index: 21;
  width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--line);
  background: var(--card); color: var(--text); font-size: 20px;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; padding: 0; font-family: var(--font);
}
.a11y-panel-toggle:hover { border-color: var(--text); }
.a11y-panel {
  position: absolute; top: calc(3vh + 52px); right: calc(4vw + 56px); z-index: 20;
  background: var(--card); border: 1.5px solid var(--line); border-radius: 14px;
  padding: 10px; display: flex; flex-direction: column; gap: 8px; align-items: stretch;
  box-shadow: 0 10px 30px rgba(0,0,0,.15);
}
.a11y-panel[hidden] { display: none; }
.a11y-panel .lang-toggle,
.a11y-panel .font-size-toggle,
.a11y-panel .contrast-toggle {
  position: static; top: auto; left: auto; right: auto;
}
.lang-toggle {
  position: absolute; top: 3vh; right: 4vw; z-index: 20;
  display: flex; gap: 4px; background: var(--card); border: 1.5px solid var(--line);
  border-radius: calc(var(--radius-pill) * var(--ui-radius-scale)); padding: 4px;
}
.lang-toggle[hidden] { display: none; }
.lang-toggle-btn {
  background: transparent; border: 0; border-radius: calc(var(--radius-pill) * var(--ui-radius-scale));
  min-width: 44px; height: 36px; padding: 0 14px; font-family: var(--font);
  font-size: calc(14px * var(--ui-font-scale)); font-weight: 800; letter-spacing: .02em;
  color: var(--ink-soft); cursor: pointer;
}
.lang-toggle-btn.active { background: var(--text); color: var(--bg); }
/* 큰글씨 고객 토글(2026-07-23) — lang-toggle과 같은 시각언어(카드/보더/필배지) 재사용하되
   반대쪽 모서리(좌상단)에 둬서 lang-toggle(우상단, 켜진 매장만)과 절대 안 겹친다. */
.font-size-toggle { left: 4vw; right: auto; }
/* 고대비 모드 토글(2026-07-26, 라운드11 #7) — font-size-toggle과 같은 좌상단 모서리, 그
   바로 아래에 쌓아 lang-toggle(우상단)·font-size-toggle과 전부 안 겹치게 한다. */
.contrast-toggle { left: 4vw; right: auto; top: calc(3vh + 50px); }

/* 보조버튼(음성안내·직원호출) 위치 — 2026-08-07 전면 재배치.
   ────────────────────────────────────────────────────────────────────────
   종전엔 둘 다 좌우 **하단** 모서리(bottom:3vh)에 있었고, 바로 위 주석은 "진행화면 어디서든
   안 겹치는 우하단"이라고 단언하고 있었다. **틀렸다.** 진행화면 하단 액션바의 "다음 →"·"뒤로"가
   정확히 그 모서리를 쓰기 때문에, 실측하면 6개 화면 전부에서 52x42~48px씩 겹쳐 있었다
   (§tools/gate_overlap.py). 손님이 "다음" 버튼의 안쪽 모서리를 누르면 직원이 호출된다 —
   무인 매장에서 가장 나쁜 종류의 오작동이다.

   주석이 틀린 채 9개월 남아 있었던 이유는 아무도 좌표를 안 쟀기 때문이다. 그래서 이번엔
   위치를 고치면서 **게이트(gate_overlap.py)를 같이 만들었다** — 다음에 누가 하단바를
   바꾸면 주석이 아니라 숫자가 막는다.

   새 위치: 상단 우측. 진행화면 상단바는 좌측 홈·중앙 타이틀만 쓰고 우측은 타이머뿐이라
   (그 타이머는 아래 .lp-topbar 여백으로 밀어 자리를 비웠다) 어떤 조작요소와도 안 만난다.
   인쇄·대기화면엔 상단바 자체가 없어 더 여유롭다. 결제모달 등(z-index 500+)보다 낮게 둬
   모달이 뜨면 자연히 가려지는 것은 그대로.

   크기도 52 → 46px. 손가락 최소치(44px)는 지키면서, 손님이 실제로 쓸 일이 드문 보조기능이
   화면에서 차지하는 존재감을 줄인다(동주님: "필요없는 기능 요소 최소화해서 필요한 게 잘
   보이도록"). */
/* ★유틸 바의 자식으로 바뀜(2026-08-16, 동주님 "직원 호출 칸이 버튼 디자인 넘어가는거").
   버그 둘이 겹쳐 있었다:
   ① 이 규칙 끝에 `padding: 0`이 한 번 더 있어 앞줄의 `padding: 0 14px`를 덮었다.
      알약 안쪽 여백이 0이라 글자가 테두리에 딱 붙고, border-radius:999px가 그 끝을
      깎아 **글자가 버튼 밖으로 새어 나온 것처럼** 보였다.
   ② 직원호출 버튼을 `right: calc(4vw + 148px)`로 밀어 뒀다 — 148은 음성안내 버튼의
      폭을 손으로 잰 값이라, 글자 길이·언어·글자배율이 바뀌면 그대로 겹치거나 벌어진다.
      두 버튼을 flex 한 줄에 넣어 그 마법숫자를 없앤다(폭은 브라우저가 잰다). */
#kiosk-util-bar {
  position: fixed; right: 4vw; top: 3vh; z-index: 40;
  display: flex; align-items: center; gap: 10px; pointer-events: none;
}
#kiosk-util-bar > * { pointer-events: auto; }
.lp-callstaff-btn { order: 1; }   /* 직원호출이 왼쪽, 음성안내가 오른쪽(종전 배치 유지) */
.voice-guide-btn { order: 2; }
.voice-guide-btn {
  position: static;
  min-height: 46px; height: auto; width: auto; padding: 0 14px; gap: 7px;
  border-radius: 999px; border: 1.5px solid var(--line);
  background: var(--card); color: var(--text); font-size: 20px;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.12);
  opacity: .72; transition: opacity var(--dur-quick) var(--ease-out);
}
.voice-guide-btn:hover, .voice-guide-btn:focus-visible { opacity: 1; }
.voice-guide-btn[hidden] { display: none; }
.voice-guide-btn:active { transform: scale(.94); }

/* 직원 호출 버튼 전체흐름 상시노출(2026-07-28, 경쟁사/트렌드 리서치 라운드14 finding 2) —
   기존엔 §12-181 결제실패 화면(.pay-fail-big 안 #pay-modal-callstaff)에만 있어 WCAG 2.2
   SC 3.2.6(Consistent Help)이 요구하는 "흐름 전체 동일 위치 노출"이 안 됐었다. voice-guide-btn과
   완전히 동일한 패턴(document.body에 직접 붙는 position:fixed, §app.js initCallStaffBtn)이되
   좌우만 뒤집어 우하단(음성안내)과 안 겹치는 좌하단에 둔다. 결제모달 등(z-index 500+)보다는
   낮게 둬 모달이 뜨면 자연히 가려짐 — voice-guide-btn과 동일 z-index로 서로도 안 겹침(각각
   반대편 모서리). */
/* 직원호출 — 음성안내 바로 왼쪽(46px + 10px 간격). 위 .voice-guide-btn 주석 참조. */
/* 아이콘+글자(2026-08-15) — 원형 아이콘 버튼은 뜻이 안 읽힌다는 현장 지적에 따라 알약형으로.
   두 버튼이 같은 줄에 서므로 오른쪽 여백은 §.voice-guide-btn 폭 + 간격만큼 벌린다. */
.lp-callstaff-btn {
  position: static;
  min-height: 46px; height: auto; width: auto; padding: 0 14px; gap: 7px;
  border-radius: 999px; border: 1.5px solid var(--line);
  background: var(--card); color: var(--text); font-size: 20px;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.12);
  opacity: .72; transition: opacity var(--dur-quick) var(--ease-out);
}
.lp-callstaff-btn:hover, .lp-callstaff-btn:focus-visible { opacity: 1; }
.lp-callstaff-btn[hidden] { display: none; }
.lp-callstaff-btn:active { transform: scale(.94); }
.lp-callstaff-btn:disabled { opacity: .6; cursor: default; }

/* ============================================================
   2) 상품
   ============================================================ */
.grid {
  flex: 1; min-height: 0; display: grid; gap: 2.6vh 2.6vw; grid-template-columns: repeat(var(--product-grid-cols, 2), 1fr);
  /* `safe center`(2026-08-06) — 종전엔 그냥 `center`였는데, 중앙정렬 컨테이너는 내용이
     넘치면 **위아래가 똑같이 잘리고 스크롤로도 닿지 않는다.** 실측: 1920×1080 화면에
     상품 5개(2열×3행=1236px)를 깔면 마지막 상품 "여섯컷"(₩6,000, 최고가)의 top이 1045px로
     화면 밖이었고, 스크롤 컨테이너도 없어 **손님이 도달할 방법이 아예 없었다.** 상품이 5개를
     넘거나 화면이 낮은 매장에서 그대로 매출을 잃는다.
     `safe`는 넘칠 때만 start 정렬로 물러나므로, 들어맞는 매장(대부분)의 화면은 1px도 안 변한다. */
  align-content: safe center; overflow-y: auto; overscroll-behavior: contain;
  /* 폭 상한을 **열 하나당**으로 바꾼다(2026-08-09, 인천대 팝업 부스 = 가로 1920x1080 확정).
     ────────────────────────────────────────────────────────────────────────────
     종전 `max-width:1080px`는 화면이 넓어져도 격자를 1080px에 묶어뒀다. 그런데 §app.js
     paginateLayoutGrid는 "한 화면에 다 보이게" 열을 늘리므로(상품 5종 → 5열), 넓은 화면일수록
     **같은 1080px 안에 열만 늘어나 카드가 가늘어진다.** 실측(상품 5종, 5열):
       1920x1080  쓸 수 있는 폭 1805 중 1080만 사용 → 725px(40%)가 검은 여백,
                  카드 **176x836** = 1:4.75 의 기둥. 상품 목업은 130cqw 상한에 걸려
                  199px에서 멈추고, 설명("세로 3컷 스트립 · 2매 출력")은 153px 칸에서 2줄로 깨진다.
       1280x720   1203 중 1080 → 남는 123px(10%)뿐이라 이 상한이 거의 안 걸렸다.
       1080x1920  1015 중 1013 → 뷰포트가 1080보다 좁아 **이 상한은 아예 안 걸린다.**
     즉 이건 **가로 화면에서만 나타나는 결함**이었고, 그래서 지금까지 잰 두 규격(1280x720·
     세로)에서는 한 번도 안 보였다. 상품 선택은 매출이 시작되는 화면이라 "덜 만든 화면"으로
     보이는 대가가 가장 큰 자리다.

     ★`max(1080px, …)`로 감싼 이유 — 상한은 **오늘보다 커지기만 하고 절대 작아지지 않는다**.
       그래서 세로 매장과 열이 적은 매장은 픽셀 하나 안 변한다(검증: 1080x1920 회귀 0).
     ★폭을 그냥 vw로 풀지 않은 이유 — 2열 매장이 1920에서 카드 **877px**짜리가 된다.
       열당 상한이라 열이 적으면 격자도 좁게 유지된다(2열@1920 = 1080px 그대로).
     실측 결과(1920x1080, 5열): 격자 1080 → 1805, 카드 176 → 321px, 목업 199 → 260px. */
  max-width: min(100%, max(1080px, calc(var(--product-grid-cols, 2) * 20vw)));
  margin: 0 auto; width: 100%;
}
/* 상품 페이저(2026-08-07, §app.js paginateLayoutGrid) —────────────────────────
   ★`[hidden]`을 CSS로 한 번 더 눌러야 한다. `hidden` 속성이 걸어주는 것은 **UA 스타일시트**의
   `display:none`인데, 아래 `.card { display:flex }`는 **작성자 스타일**이라 특이도와 무관하게
   UA를 이긴다. 이 규칙이 없으면 `card.hidden = true`가 아무 일도 안 하고 페이저가 조용히
   무력화된다(= 넘침이 그대로 남는데 화면엔 페이저가 떠 있어 더 나쁘다). */
#layout-list .layout-card[hidden] { display: none; }
/* 격자 아래 빈 띠 흡수(2026-08-07) —───────────────────────────────────────────
   `.paged`는 §app.js paginateLayoutGrid가 **페이저를 실제로 켤 때만** 붙이고, 그때 같이 실어
   보내는 `--card-row-h`는 "rowsFit 행이 격자 높이를 정확히 나눠 가진 값"이다.
   왜 필요한가 — 페이지네이션은 행 단위로 자르므로 남는 높이가 생기는데, `align-content:
   safe center`가 그걸 위아래 두 덩이로 갈라 **가로 빈 띠**를 만든다(실측 1280x800·상품 5개·
   3열: 격자 593px에 카드 458px 한 행 → 위 67px / 아래 68px = 본문의 10%).
   ★`align-content:stretch`로 맡기지 않고 트랙 높이를 못 박는다 — 행이 rowsFit보다 적은
     페이지(마지막 페이지)에서 한 행이 예산을 통째로 삼켜 카드가 기괴하게 늘어난다.
     칩 쪽에서 실측으로 걸린 결함이라(칩 하나가 99x418px) 여기도 같은 규율로 막는다.
   ★넘칠 수 없다: rowsFit 정의상 `rowsFit*카드높이 + 간격 <= 격자높이`라 역산한 트랙 높이는
     항상 원래 카드 높이 이상이고, JS가 floor로 내려 반올림 1px 넘침도 막는다.
   ★페이저가 안 켜지는 매장(상품이 다 들어감)에는 이 클래스가 없다 = 화면이 1px도 안 변한다. */
#layout-list.paged { align-content: start; grid-auto-rows: var(--card-row-h, auto); }
/* 늘어난 카드 안에서 내용이 위에 붙어 있으면 빈 띠가 **카드 안으로** 옮겨갈 뿐이다. 가운데로. */
#layout-list.paged .card { justify-content: center; }
.grid-pager {
  display: flex; align-items: center; justify-content: center; gap: var(--space-6);
  flex: 0 0 auto; margin-top: var(--space-3);
}
.grid-pager[hidden] { display: none; }   /* 위와 같은 이유 — flex가 UA의 none을 이긴다 */
.grid-pager-btn {
  /* 터치 타깃 하한(44px)보다 넉넉하게 — 서서 조작하는 키오스크라 화살표는 크게 잡는다. */
  min-width: 64px; min-height: 64px; padding: 0 var(--space-4);
  background: var(--card); color: var(--text);
  border: 1.5px solid var(--line); border-radius: calc(var(--radius-6) * var(--ui-radius-scale));
  font-size: calc(34px * var(--ui-font-scale)); font-weight: 700; line-height: 1; cursor: pointer;
  transition: border-color var(--dur-quick) var(--ease-out), transform var(--dur-move) var(--ease-spring);
}
.grid-pager-btn:hover:not(:disabled) { border-color: var(--text); }
.grid-pager-btn:active:not(:disabled) { transform: translateY(2px); }
/* 끝 페이지에서 화살표를 **지우지 않고 흐리게만** 둔다 — 사라지면 버튼 위치가 밀려
   손님이 방금 누른 자리가 다른 버튼이 된다(오조작). 위치는 고정, 상태만 바꾼다. */
.grid-pager-btn:disabled { opacity: .28; cursor: default; }
.grid-pager-label {
  font-family: var(--mono); font-size: calc(22px * var(--ui-font-scale)); font-weight: 700;
  color: var(--text); display: inline-flex; align-items: baseline; gap: 2px;
}
.grid-pager-label .gp-sep { color: var(--ink-soft); margin: 0 2px; }
.grid-pager-count { font-family: var(--font); font-size: calc(15px * var(--ui-font-scale)); color: var(--ink-soft); margin-left: var(--space-4); letter-spacing: 0; }
/* 목업 확대(§C) — 페이저가 켜져 한 페이지에 한 행만 놓이면 카드 세로 예산이 크게 남는다.
   그 여유를 **목업**에 준다. 레퍼런스(포토이즘)의 상품 카드는 목업이 카드 높이의 절반 가까이를
   차지하고, 손님은 이름이 아니라 **모양**을 보고 고른다. 종전 12vh는 화면의 12%뿐이었다.
   ★`#layout-list` 스코프로만 건다 — `.layout-mockup` base는 대기화면 히어로(`#idle-hero`
   스코프)와 재인쇄 목록도 상속하는 공용값이라 건드리면 그쪽이 함께 망가진다.
   ★상한(clamp 최대)이 있으므로 페이저가 안 켜진 매장(상품 3개 이하)에서도 카드가 무한정
   커지지 않는다. 다만 이 값은 페이저 계산의 입력이라, 넘치면 페이저가 알아서 켜진다. */
/* ★위 `height: 30vh`는 **고정 예약석**이었다(2026-08-09, D-2 실측으로 확인).
   1280x720 · 상품 5개 · 5열에서 격자 예산은 516px인데 목업이 216px(=30vh, 카드의 42%)을 먼저
   가져가고 남은 300px에 이름·설명·컷수·가격이 들어가야 한다. 글자가 늘면 목업은 안 물러나므로
   카드가 그대로 커진다 —
     · 손님이 '아주 큰 글자'(1.3배)를 누르면 글자 뭉치가 230→289px, 카드 509→568px.
       실측: 격자를 76px 넘겨 **5개 중 4개의 가격이 목록 밖**이었다(₩4,000·₩5,000·₩5,000·₩6,000).
     · 상품명이 "인천대학교 학위수여식 기념 세컷 스트립"이면 제목만 350px, 카드 661px.
       실측: 가격 ₩4,000이 화면(720px) 밖 **786px**에 찍혔다.
   상품 페이저(§app.js paginateLayoutGrid)는 이걸 못 잡는다: 5열이라 행이 하나뿐인데
   `rowsFit = Math.max(1, ...)`가 **한 행은 언제나 들어간다**고 세기 때문이다 — 한 행이 예산보다
   큰 상태가 그 식의 사각지대다. 그래서 카드가 스스로 예산 안에 들어와야 한다.

   ★고치는 방식: 목업을 **줄어들 수 있는 요소**로 바꾼다. 글자는 못 줄인다(읽어야 하니까) —
     그러니 남는 자리를 그림이 내놓는다. `.layout-icon`이 `flex: 1 1 auto`로 카드의 남는 높이를
     받고, 목업은 그 안을 `height: 100%`로 채우되 `max-height`를 넘지 않는다:
       · 자리가 남으면 그림이 max-height까지 자란다 → 실측: 기본 1280x720·5열에서 목업 214px
         (이 수정 전 216px과 사실상 같다 = 기존 매장 무회귀).
       · 자리가 모자라면 flex-shrink로 물러난다 → 실측: 같은 화면 + 긴 상품명에서 184px.
         가격이 먼저 산다.
     `min-height: 0`이 있어야 flex 아이템이 실제로 줄어든다(자동 최소 크기 해제).
   ★`.layout-icon`에 목업과 **같은 max-height**를 한 번 더 거는 이유: 안 걸면 아주 높은 카드
     (1080x1920 세로화면)에서 아이콘 칸이 남는 높이를 **전부** 삼켜 글자가 카드 바닥에 붙는다.
     같은 값으로 묶어두면 그 이상 남는 높이는 `.card-inner`의 `justify-content: center`가 받아
     그림+글자 덩어리가 카드 가운데에 선다(종전 `safe center`가 만들던 그림과 같은 모양).
   ★상한 30vh는 **그대로 둔다**(2026-08-07의 "목업 확대" 결정을 되돌리지 않는다). 실측으로
     확인: 기본 1280x720·5열에서 목업 214px — 이 수정 전(216px)과 사실상 같다. 같은 화면에서
     긴 상품명을 넣으면 같은 규칙이 184px로 물러난다. 함께 건 `130cqw`는 열을 아주 많이 둔
     매장에서 그림이 카드 폭보다 커지지 않게 하는 가로 기준이고, 지금 화면에서는 카드 콘텐츠 폭
     166px × 1.30 = 216px라 30vh와 같은 선이어서 **안 문다**.
   하한을 96→64px로 내린 이유: 양보할 여지 자체가 있어야 이 규칙이 일한다. 64px은 공용
   `.layout-mockup` base의 하한과 같은 값이라 새 기준을 만든 것이 아니다. */
#layout-list .layout-icon {
  flex: 1 1 auto; min-height: 0; max-height: clamp(64px, min(30vh, 130cqw), 260px);
  display: flex; align-items: center; justify-content: center;
  /* ★align-self: stretch — 아래 목업의 `min(100%, …)`가 기준으로 삼을 **확정된 폭**을 준다
     (2026-08-15). `.card-inner`가 `align-items:center`라 이 칸은 기본적으로 내용 크기로
     줄어드는데, 그러면 목업의 100%가 자기 자신을 가리켜 순환이 생기고 폭이 2px로 붕괴한다
     (실측: 목업 2x6px). stretch로 카드 폭을 받아야 100%가 뜻을 가진다. */
  align-self: stretch;
}
#layout-list .layout-mockup {
  /* ★가로 상품(1800x1200) 대응(2026-08-15, 동주님 "가로 여섯컷 UI에서 벗어나는거 해결해").
     종전에는 height로만 크기를 정했다: 높이 예산 260px × 비율 1.5 = 폭 390px인데 카드는
     319px이라 **좌우로 36px씩 삐져나왔다**(1080x1920 실측). max-width로 폭만 자르면 이번엔
     비율이 깨져 가로 상품이 정사각형처럼 보인다 — 손님이 모양을 보고 고르는 화면에서
     모양을 거짓말하는 셈이다.
     그래서 **폭을 기준으로 삼되 세로 예산을 넘지 않게** 계산한다:
       폭 = min(카드가 주는 폭, 세로예산 × 종횡비)
     세로 상품(비율 0.333)은 260×0.333=87px이 100%보다 작아 종전과 같은 결과가 나온다
     (실측 86x260 → 그대로). 가로 상품만 폭에 걸려 287x191로 줄어 카드 안에 들어온다.
     --ar은 §app.js buildLayoutMockup이 인라인으로 실어 준다(없으면 세로 2:3로 가정). */
  /* ★크기는 §app.js fitLayoutMockups()가 **잰 값으로 인라인 지정**한다(2026-08-15).
     여기 CSS로만 풀려다 두 번 실패했다:
       · height 기준(종전) → 가로 상품에서 폭 390px, 카드 319px 밖으로 삐져나감(실측)
       · width: min(100%, …) → 100%가 부모 폭이고 부모 폭은 이 요소 내용으로 정해져
         **순환**이 생겨 목업이 2px로 붕괴(실측)
     아래 값은 JS가 아직 안 돌았을 때(1프레임)의 폴백이다 — 넘치지 않는 쪽으로 보수적으로. */
  height: 100%;
  max-height: clamp(64px, min(30vh, 130cqw), 260px);
  max-width: 100%;
}
/* 남는 세로 예산을 **행에** 돌려준다 — 그래야 위 목업이 자랄 자리가 생긴다.
   종전 `safe center`는 남는 높이를 카드 위아래 빈 띠로 갈라놓았다(§.grid 주석의 1280x800 실측:
   67px/68px). `stretch`는 남을 때만 행을 늘리고, 넘칠 때는 자유공간이 음수라 start로 물러나므로
   `safe center`가 막으려던 "가운데 정렬이 위아래를 똑같이 잘라 스크롤로도 못 닿는" 상태도
   그대로 안 생긴다. `.paged`가 붙는 매장은 **위 §격자 아래 빈 띠 흡수**의
   `#layout-list.paged { align-content: start }`가 특이도(1,1,0 > 1,0,0)로 이겨서, 행 높이를
   JS가 계산한 --card-row-h로 못 박는 기존 동작 그대로다(실측으로 확인 — 아래 페이저 시험). */
#layout-list { align-content: stretch; }
#layout-list .card-inner { flex: 1 1 auto; min-height: 0; justify-content: center; }
#layout-list .mockup-cell {
  background: color-mix(in srgb, var(--pink) 18%, var(--card)); opacity: 1;
  display: flex; align-items: flex-end; justify-content: center; overflow: hidden;
}
/* 실루엣 대비(2026-08-07, WCAG 1.4.11 비텍스트 3:1) — 상품 카드에서 실루엣은 장식이 아니라
   **정보**다(손님은 이름이 아니라 모양으로 고른다). 공용 `.idle-hero-person`의 opacity .55는
   대기화면 콜라주용 값이라 여기선 부족했다 — 실측(getComputedStyle 합성 계산):
     신규 기본테마(다크 #1F1B17): 실루엣/셀 **2.897:1** (미달)
     _shotdata 매장(라이트 크림):              **2.522:1** (미달)
   .70으로 올려 두 테마 모두 3:1을 넘긴다(아래 검증 수치 참고).
   ★인계서(§docs/PHOTOISM_QUEUE.md §A)가 제안한 "셀 믹스 18%→22%"는 **반대 방향이었다** —
     실루엣이 셀보다 밝으므로 셀을 밝히면 대비가 준다(실측 2.897→2.814). 12%·8%로 낮추면
     3.008·3.07로 턱걸이하지만 셀/카드 대비가 1.216→1.13·1.08로 무너져 프레임 윤곽이 사라진다.
   ★`#layout-list` 스코프 한정 — 대기화면 히어로(#idle-hero)의 콜라주 톤은 그대로 둔다. */
#layout-list .idle-hero-person { opacity: .7; }
/* ── 상품 카드를 자기 폭 기준으로 재게 한다(2026-08-09, D-2) ────────────────────────────
   결함 실체: 카드 안의 글자 크기와 좌우 여백이 전부 **뷰포트 폭(vw)** 기준이었다. 열 수는
   매장 설정(--product-grid-cols)이라 같은 1280px 화면에서도 카드 폭이 2열이면 520px, 5열이면
   189px인데, 글자는 어느 쪽이든 `3vw = 38.4px`를 요구하고 여백도 좌우 `3vw`씩 가져간다.
   실측(1280x720·5열): 카드 189px 중 여백이 76.8px을 먹어 **글자가 쓸 수 있는 폭은 112px**,
   거기에 32px 제목이 들어가니 "인천대학교 학위수여식 기념 세컷 스트립" 같은 실제 상품명이
   **350px(7줄 이상)** 로 늘어나 카드가 661px이 되고 가격 ₩4,000이 화면(720px) 밖 786px에
   찍혔다. 손님이 값을 모르는 상품이 생긴다.
   ★이름을 line-clamp로 자르지 않는다 — 무엇을 사는지 감춘 채 가격만 보이면 더 나쁘다.
     대신 **카드 폭에 맞춰 재게** 한다: container-type으로 카드를 질의 컨테이너로 만들고,
     여백은 %(컨테이닝 블록 = 그리드 트랙 폭 기준), 글자는 cqw(카드 콘텐츠 폭 기준)로 상한을
     건다. 좁은 카드에서는 자동으로 여백이 줄고 글자가 작아져 같은 이름이 4줄로 접힌다.
   ★min()의 다른 쪽에 기존 vw 값을 그대로 남겨 **넓은 카드에서는 종전과 동일**하다(무회귀).
     2열 매장(기본값)의 카드는 폭이 넓어 vw 쪽이 계속 이긴다.
   ★여백에 cqw를 쓰지 않고 %를 쓰는 이유: cqw는 컨테이너 자신의 콘텐츠 폭에서 나오는데 그
     폭을 padding이 정한다 — 자기참조라 무효가 된다. %는 트랙 폭 기준이라 순환이 없다. */
#layout-list .layout-card { container-type: inline-size; }
#layout-list .card { padding-left: min(3vw, 6%); padding-right: min(3vw, 6%); }
#layout-list .card-title {
  font-size: clamp(calc(16px * var(--ui-font-scale)),
                   min(calc(3vw * var(--ui-font-scale)), 17cqw),
                   calc(32px * var(--ui-font-scale)));
}
#layout-list .card-desc {
  font-size: clamp(calc(11px * var(--ui-font-scale)),
                   min(calc(1.7vw * var(--ui-font-scale)), 11cqw),
                   calc(18px * var(--ui-font-scale)));
}
.card {
  background: var(--card); border: 1.5px solid var(--line); border-radius: calc(var(--radius-6) * var(--ui-radius-scale));
  padding: 3.4vh 3vw; cursor: pointer; color: var(--text);
  display: flex; flex-direction: column; align-items: center; gap: var(--space-3); text-align: center;
  transition: transform var(--dur-move) var(--ease-spring), border-color var(--dur-quick) var(--ease-out), box-shadow var(--dur-move) var(--ease-out); position: relative;
}
.card::after { /* 사진 코너 한 점 */
  content: ""; position: absolute; top: 12px; left: 12px; width: 16px; height: 16px;
  border-left: 2px solid var(--line-strong); border-top: 2px solid var(--line-strong);
}
/* 버튼 카드 내용 래퍼(2026-08-06) — §app.js buildLayoutCards 주석 참고.
   <button>은 자식 높이를 고유 높이로 안 올려주므로, 실제 세로 배치는 이 래퍼가 맡는다. */
.card-inner {
  display: flex; flex-direction: column; align-items: center; gap: var(--space-3);
  width: 100%; min-width: 0;
}
/* 내용 기준 최소높이(2026-08-06, 계산값 실측으로 확정) — 접근성 터치타깃 하한 규칙이
   버튼에 `min-height: 44px`를 걸면서 flex/grid 아이템의 **자동 최소 크기(min-height:auto)가
   사라졌다.** 자동 최소 크기는 "내용보다 작아지지 않는다"를 보장하는 장치인데, 그게 없어지자
   그리드가 카드를 행에 맞춰 157px로 짓눌렀고 내용(273px)이 114px 흘러넘쳐 **아래 카드의
   제목·가격 위에 겹쳐 그려졌다.** max-content는 44px보다 항상 크므로 터치 하한도 그대로
   지켜진다(접근성 무회귀). 실촬영으로 확인한 결함이고, DOM 측정만으로는 요소가 '존재'하기
   때문에 잡히지 않았다. */
.screen .card { min-height: max-content; }
.card:active { transform: translateY(2px); }
.card:hover { border-color: var(--text); box-shadow: var(--shadow-sig-lg); }
.layout-icon {
  font-family: var(--mono); font-size: clamp(calc(28px * var(--ui-font-scale)), calc(5vw * var(--ui-font-scale)), calc(46px * var(--ui-font-scale))); color: var(--pink); letter-spacing: 2px;
  line-height: 1; margin-bottom: 4px;
}
/* 상품 카드 실제 그리드 목업(2026-07-14, layoutRects() 재사용 — §buildLayoutMockup) */
/* 높이 상한(2026-08-06, 헤드리스 실촬영으로 발견) — 목업 폭만 정하고 높이를 용지 비율에
   맡겨두면 2×6인치 세로 스트립은 **폭의 3배**가 된다(78px → 234px). 카드 내용 높이는
   패딩 제외 약 110px이라, 세로형 상품 카드마다 목업이 카드를 뚫고 나가 **아래 카드의
   제목·가격 위에 겹쳐 그려졌다** — 실촬영에서 "네컷 그리드"와 "대형 한컷 ₩5,000"이
   다음 카드 문구와 포개져 읽을 수 없었다. 상품 선택은 미팅에서 두 번째로 보는 화면이다.
   폭 대신 **높이를 기준**으로 잡고 폭이 비율을 따라가게 뒤집는다 — 그래야 가로형(4×6)과
   세로형(2×6)이 같은 높이 줄에 나란히 서고, 어느 쪽도 카드를 넘지 않는다. */
.layout-mockup {
  height: clamp(64px, 12vh, 116px); width: auto; max-width: clamp(46px, 11vw, 78px);
  position: relative; background: var(--bg); flex: 0 0 auto;
  border: 1.5px solid var(--line); border-radius: calc(var(--radius-3) * var(--ui-radius-scale)); overflow: hidden;
}
.layout-mockup > svg { height: 100%; width: auto; display: block; }
.layout-mockup .mockup-cell {
  position: absolute; background: var(--pink); opacity: .5; border-radius: 0;   /* 사진칸 = 직각 */
}
/* word-break:keep-all (2026-08-08) — 한국어는 기본 규칙(break-all에 가까운 CJK 처리)에서
   **단어 한복판이 잘린다**. 실측: 상품 카드가 좁아지자 "네컷 그리 / 드", "세로 4컷 스트립
   (2매 출 / 력)"으로 끊겨 읽기 어려웠다(헤드리스 실렌더 PNG로 확인). keep-all은 어절
   경계에서만 끊으므로 한국어 문구가 자연스럽게 접힌다. 영어에는 영향이 없다(원래 어절 단위).
   좁은 칸에서 어절 하나가 칸보다 길면 넘칠 수 있어 overflow-wrap:anywhere를 함께 둔다 —
   "평소엔 어절 단위, 정말 안 되면 그때만 자른다". */
.card-title { font-size: clamp(calc(21px * var(--ui-font-scale)), calc(3vw * var(--ui-font-scale)), calc(32px * var(--ui-font-scale))); font-weight: 800; letter-spacing: -.02em;
  word-break: keep-all; overflow-wrap: anywhere; }
.card-desc { color: var(--ink-soft); font-size: clamp(calc(13px * var(--ui-font-scale)), calc(1.7vw * var(--ui-font-scale)), calc(18px * var(--ui-font-scale))); font-weight: 500;
  word-break: keep-all; overflow-wrap: anywhere; }
.card-meta { font-family: var(--mono); color: var(--ink-soft); font-size: calc(14px * var(--ui-font-scale)); letter-spacing: .1em; }
.price {
  margin-top: 8px; font-family: var(--mono); color: var(--text); font-weight: 700;
  font-size: clamp(calc(19px * var(--ui-font-scale)), calc(2.5vw * var(--ui-font-scale)), calc(28px * var(--ui-font-scale))); border-top: 1.5px solid var(--line); padding-top: var(--space-3); width: 70%;
}

/* ============================================================
   3) 결제 (하루필름 Order.vue 좌우2단 — 좌:계산기+쿠폰 / 우:상품확인+결제수단)
   ============================================================ */
.pay-split {
  flex: 1; display: grid;
  /* fr 단위는 calc() 안에서 못 씀(실측으로 발견 — grid-template-columns 선언 전체가 무효화돼
     암시적 단일 컬럼으로 무너짐, 좌우 분할이 아예 사라지는 회귀버그였음) — % 트랙으로 대체.
     그리드의 % 트랙은 gap을 뺀 나머지 공간 기준으로 분배되므로 fr 2개일 때와 동일하게 동작. */
  grid-template-columns: calc(var(--pay-split-l, 50) * 1%) calc((100 - var(--pay-split-l, 50)) * 1%);
  gap: 3vw; max-width: 1200px; margin: 0 auto; width: 100%; align-items: stretch; min-height: 0;
}
.pay-calc, .pay-option {
  background: var(--card); border: 1.5px solid var(--line); border-radius: calc(var(--radius-8) * var(--ui-radius-scale));
  padding: 4vh 3vw; display: flex; flex-direction: column; gap: var(--space-8); min-height: 0; overflow-y: auto;
}
.pay-calc { align-items: center; justify-content: center; text-align: center; }
.pay-calc-total { display: flex; align-items: baseline; justify-content: center; }
.pay-calc-amount { font-family: var(--mono); font-weight: 800; font-size: clamp(calc(32px * var(--ui-font-scale)), calc(4.6vw * var(--ui-font-scale)), calc(52px * var(--ui-font-scale))); color: var(--pink); }
.pay-calc-currency { font-size: clamp(calc(16px * var(--ui-font-scale)), calc(2.2vw * var(--ui-font-scale)), calc(24px * var(--ui-font-scale))); font-weight: 700; margin-left: 6px; }
.pay-calc-sub { min-height: 1.2em; color: var(--ink-soft); font-weight: 500; }
.pay-calc-sub s { margin-right: 8px; }
.pay-calc-idle p { color: var(--ink-soft); font-size: clamp(calc(15px * var(--ui-font-scale)), calc(1.9vw * var(--ui-font-scale)), calc(19px * var(--ui-font-scale))); font-weight: 600; }

.pay-step-heading { font-size: clamp(calc(16px * var(--ui-font-scale)), calc(1.9vw * var(--ui-font-scale)), calc(21px * var(--ui-font-scale))); font-weight: 800; margin: 0 0 12px; }
.pay-product-summary {
  background: var(--bg); border: 1px solid var(--line); border-radius: calc(var(--radius-7) * var(--ui-radius-scale)); padding: var(--space-6) 18px; font-weight: 600;
}
/* 결제화면 상품요약 배치 목업(2026-07-30, features.payLayoutPreview opt-in — §app.js
   renderPayProductSummary). 게이트가 꺼진 매장엔 이 요소 자체가 DOM에 안 생기므로 위
   .pay-product-summary는 기존과 픽셀 단위로 동일하다(무회귀). SVG 폭·높이는 JS가 용지
   비율(layout.canvas)로 직접 계산해 속성으로 박으므로 여기서 크기를 덮어쓰지 않는다 —
   CSS로 width를 강제하면 2×6인치와 4×6인치가 같은 폭으로 보여 목적 자체가 무너진다. */
.pay-mockup { display: flex; align-items: center; gap: 10px; margin-top: var(--space-5); color: var(--text); }
.pay-mockup-svg { flex: 0 0 auto; display: block; }
.pay-mockup-cap { font-size: calc(12px * var(--ui-font-scale)); font-weight: 600; opacity: .72; line-height: 1.35; }
.pay-method-buttons { display: flex; gap: var(--space-6); flex-wrap: wrap; }
/* --own-w / --touch-min-w 쌍(2026-07-31): 디자인 최소폭은 --own-w 한 곳에만 적고, 접근성
   하한 규칙(§터치타깃 하한)이 읽을 값은 그것과 44px 중 큰 쪽으로 넘긴다. 이 쌍이 없으면
   하한 규칙의 특이도가 여기 min-width를 눌러 110px → 44px로 **작아진다**(실측 확인). */
.pay-method-btn {
  --own-w: 110px; --touch-min-w: max(var(--own-w), var(--touch-floor));
  /* 높이 상한(2026-08-06, 헤드리스 실촬영으로 발견) — aspect-ratio:1/0.8 + flex:1이라 컬럼이
     넓을수록 버튼이 같이 커져 1280x720에서 240x192px가 됐다. 두 개면 세로로 192px를 먹어
     **결제수단 버튼이 화면 아래로 잘려 손님이 결제를 진행할 수 없었다**(우측 컬럼이
     overflow-y:auto라 스크롤은 되지만, 터치 키오스크엔 스크롤바가 없어 잘린 건 없는 것과 같다).
     터치 하한(44px)과 실사용 여유(60px+)를 넉넉히 넘기되 화면을 먹지 않는 높이로 고정한다. */
  flex: 1; min-width: var(--touch-min-w);
  min-height: clamp(72px, 11vh, 104px); max-height: clamp(72px, 11vh, 104px);
  border-radius: calc(var(--radius-7) * var(--ui-radius-scale)); background: var(--bg);
  border: 1.5px solid var(--line); display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-3); font-weight: 700; font-size: clamp(calc(13px * var(--ui-font-scale)), calc(1.5vw * var(--ui-font-scale)), calc(16px * var(--ui-font-scale))); cursor: pointer; color: var(--text);
  transition: border-color var(--dur-quick) var(--ease-out), box-shadow var(--dur-quick) var(--ease-out), background var(--dur-quick) var(--ease-out);
}
.pay-method-btn.selected { border-color: var(--pink); background: var(--accent-soft); box-shadow: var(--shadow-sig-md); }
.pay-method-btn[hidden] { display: none; }
.pay-method-icon-svg { width: clamp(24px, 3.4vh, 34px); height: clamp(24px, 3.4vh, 34px); }

/* 쿠폰·이용권 한 행(2026-08-07, §index.html pay-extra-row 주석). margin-top:auto는
   버튼에서 이 행으로 올라온다 — 두 버튼 각각에 걸려 있으면 행 안에서 서로 밀어내려 한다. */
.pay-extra-row { margin-top: auto; display: flex; gap: var(--space-5); flex: 0 0 auto; }
.pay-extra-row .coupon-toggle-btn { flex: 1 1 0; min-width: 0; margin-top: 0; }
.coupon-toggle-btn {
  margin-top: auto; background: transparent; border: 1.5px dashed var(--line-strong); border-radius: calc(var(--radius-8) * var(--ui-radius-scale));
  padding: var(--space-5); font-weight: 700; display: flex; align-items: center; justify-content: center; gap: var(--space-3); cursor: pointer;
  color: var(--text); font-family: var(--font); font-size: calc(14px * var(--ui-font-scale));
}
/* 한 행에 둘이 들어가면 라벨이 좁아진다 — 줄바꿈으로 뭉개지는 대신 한 줄 유지하고
   넘치면 말줄임. 아이콘은 유지해야 무엇을 넣는 자리인지 읽힌다. */
.pay-extra-row .coupon-toggle-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── #pay-option 세로 압축(2026-08-07, §app.js fitPayOption) ────────────────────
   여백 숫자를 그냥 줄이면 폰트배율·화면비가 다른 매장에서 반대로 헐거워지거나 여전히
   넘친다. **넘칠 때만** 단계적으로 조인다 — 들어맞는 매장의 화면은 1px도 안 변한다. */
#pay-option.is-compact { padding-top: 2vh; padding-bottom: 2vh; row-gap: clamp(10px, 1.7vh, 20px); }
#pay-option.is-compact .pay-step-heading { margin-bottom: 6px; }
#pay-option.is-compact .pay-product-summary { padding: var(--space-4) 14px; }
/* 2단계 — 1단계로도 모자란 경우. 실제로 걸리는 조합은 **접근성 큰글씨(1.3배) + 낮은
   화면(720)**이다(실측: 1단계만으론 467/455로 12px 초과 → 2단계로 해소).
   ★글자 크기는 건드리지 않는다 — 큰글씨를 켠 손님에게 글자를 도로 줄이면 그 기능의 목적을
     정면으로 배신한다. 줄이는 것은 **여백·버튼 높이·목업**뿐이다.
   ★결제수단 버튼은 56px로, 접근성 터치타깃 하한(44px)보다 여전히 위다. */
#pay-option.is-compact-2 { row-gap: clamp(8px, 1.3vh, 16px); }
#pay-option.is-compact-2 .pay-step-heading { margin-bottom: 4px; }
#pay-option.is-compact-2 .pay-method-btn { min-height: clamp(56px, 7.8vh, 80px); max-height: clamp(56px, 7.8vh, 80px); }
#pay-option.is-compact-2 .pay-mockup-svg { max-height: 54px; width: auto; }
#pay-option.is-compact-2 .pay-extra-row .coupon-toggle-btn { padding: var(--space-4); }
.coupon-toggle-btn.active { background: var(--pink); color: var(--on-pink); border-style: solid; border-color: var(--pink); }

/* 수량선택(2026-07-14, 포토이즘 실사 벤치마킹) */
.pay-qty-row { display: flex; align-items: center; gap: var(--space-7); }
.pay-qty-btn {
  width: var(--touch-floor); height: var(--touch-floor); border-radius: calc(var(--radius-7) * var(--ui-radius-scale)); background: var(--bg); border: 1.5px solid var(--line);
  font-size: calc(20px * var(--ui-font-scale)); font-weight: 800; color: var(--text); cursor: pointer; display: flex; align-items: center; justify-content: center;
  transition: border-color var(--dur-quick) var(--ease-out), background var(--dur-quick) var(--ease-out);
}
.pay-qty-btn:hover:not(:disabled) { border-color: var(--pink); }
.pay-qty-btn:disabled { opacity: .35; cursor: not-allowed; }
.pay-qty-value { font-size: clamp(calc(18px * var(--ui-font-scale)), calc(2.2vw * var(--ui-font-scale)), calc(24px * var(--ui-font-scale))); font-weight: 800; min-width: 2ch; text-align: center; }
.pay-qty-hint { margin-top: 8px; font-size: calc(13px * var(--ui-font-scale)); color: var(--pink-text); font-weight: 700; min-height: 1em; }
/* 수량 시각화(2026-08-08) — 인쇄될 장수를 그대로 그린다(§app.js renderQtyPreview).
   flex-wrap: 수량 상한이 10~20이라 한 줄에 다 못 들어간다. 넘치면 잘리는 게 아니라
   다음 줄로 내려가야 한다 — 이 저장소에서 "넘친다 = 없다"이기 때문이다. */
.pay-qty-preview { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 10px; min-height: 0; }
.pay-qty-preview:empty { display: none; }
.pay-qty-sheet { position: relative; height: 46px; border-radius: 3px; background: var(--card);
  border: 1.5px solid var(--line); box-shadow: 0 1px 3px rgba(0,0,0,.14); overflow: hidden; }
/* 스트립 상품은 한 장 안에 절단선을 그려 "이 한 장이 두 장이 된다"를 눈으로 보여준다. */
.pay-qty-sheet.is-strip::after { content: ''; position: absolute; top: 0; bottom: 0; left: 50%;
  border-left: 1.5px dashed var(--pink-text); opacity: .75; }
.pay-qty-more { align-self: center; font-size: calc(13px * var(--ui-font-scale)); font-weight: 800; color: var(--pink-text); }
.ticket-icon { width: 18px; height: 18px; }

/* 쿠폰 패널(QR스캔/PAD 2모드) */
.pay-coupon-modes { display: flex; gap: var(--space-3); margin-bottom: 14px; width: 100%; }
.coupon-mode-btn {
  font-size: calc(13px * var(--ui-font-scale));
  flex: 1; padding: var(--space-4); border-radius: calc(var(--radius-7) * var(--ui-radius-scale)); border: 1.5px solid var(--line); background: var(--bg);
  font-weight: 700; cursor: pointer; color: var(--text); font-family: var(--font);
}
.coupon-mode-btn.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.coupon-mode-btn[hidden] { display: none; }

.coupon-qr-video-wrap { position: relative; width: 220px; height: 220px; margin: 0 auto; border-radius: calc(var(--radius-7) * var(--ui-radius-scale)); overflow: hidden; background: #000; }
.coupon-qr-video-wrap video { width: 100%; height: 100%; object-fit: cover; }
.coupon-qr-guide {
  position: absolute; inset: 18%; border: 3px solid var(--pink); border-radius: calc(var(--radius-6) * var(--ui-radius-scale));
  box-shadow: 0 0 0 999px rgba(0,0,0,.35);
}
.coupon-qr-status { margin-top: 10px; font-size: calc(14px * var(--ui-font-scale)); font-weight: 600; color: var(--ink-soft); }
.coupon-qr-result { margin-top: 8px; font-weight: 800; color: var(--success); }

.coupon-code-boxes { display: flex; gap: var(--space-1); justify-content: center; margin-bottom: 14px; flex-wrap: wrap; max-width: 320px; }
.num-char {
  width: 24px; height: 34px; border: 1.5px solid var(--line-strong); border-radius: calc(var(--radius-5) * var(--ui-radius-scale));
  display: flex; align-items: center; justify-content: center; font-family: var(--mono);
  font-size: calc(15px * var(--ui-font-scale)); font-weight: 800; background: var(--bg);
  /* color를 명시한다(2026-08-08) — 종전엔 부모에서 물려받았다. 그래서 이 칸이 어떤 부모
     아래 놓이느냐에 따라 **배경(--bg)과 글자색이 같아져 입력한 코드가 안 보였다**
     (§.lock-overlay.code-entry 주석의 실사고). 배경을 --bg로 못박았으면 글자색도 그 짝인
     --text로 못박아야 짝이 깨지지 않는다. */
  color: var(--text);
}
/* 접근성 긴급수정(2026-07-14) — 10열×1행이라 버튼이 약 27x30px로 WCAG 최소 터치타겟(44px)
   미달이었음. 쿠폰 코드가 영숫자(0-9+A-Z, 36자)라 문자 그대로 3열×4행(12칸) 전화기 다이얼로는
   전체 문자를 담을 수 없어 — 전 문자 유지를 우선해 7열로 재배치(56px 이상 정사각형 버튼
   확보 + 6행으로 세로 길이도 절제). app.js 키패드 생성 로직은 그대로(그리드 자동배치라
   버튼 순서 변경 불필요 — 열 수만 바꿔도 자동으로 재배치됨). */
.coupon-keypad { display: grid; grid-template-columns: repeat(7, minmax(56px, 1fr)); gap: var(--space-3); max-width: 460px; margin: 0 auto; }
.keypad-btn {
  /* --own-* / --touch-min-* 쌍 = §터치타깃 하한 규칙 주석 참조(하한이 이 값을 눌러 작아지는 것 방지). */
  --own-w: 56px; --own-h: 56px;
  --touch-min-w: max(var(--own-w), var(--touch-floor)); --touch-min-h: max(var(--own-h), var(--touch-floor));
  min-width: var(--touch-min-w); min-height: var(--touch-min-h); padding: var(--space-3) var(--space-1); border-radius: calc(var(--radius-7) * var(--ui-radius-scale)); border: 1.5px solid var(--line); background: var(--bg);
  font-size: calc(15px * var(--ui-font-scale)); font-weight: 800; cursor: pointer; color: var(--text); font-family: var(--font);
}
.keypad-btn.keypad-wide { grid-column: span 2; }
.keypad-btn:active { transform: translateY(2px); background: var(--accent-soft); }
.coupon-pad-result { min-height: 1.3em; text-align: center; font-weight: 800; margin-top: 8px; }
.coupon-pad-result.ok { color: var(--success); }
.coupon-pad-result.bad { color: var(--pink-text); }
.coupon-actions { display: flex; gap: var(--space-4); justify-content: center; margin-top: 14px; }

.pay-ready-heading { font-weight: 700; margin: 0 0 14px; }
.pay-ready-rows { width: 100%; max-width: 320px; display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: 14px; }
.pay-ready-row { display: flex; justify-content: space-between; font-weight: 700; }
.pay-ready-row.discount b { color: var(--pink-text); }
.pay-ready hr { width: 100%; max-width: 320px; border: 0; border-top: 1px solid var(--line); margin: 0 0 18px; }
.pay-execute-btn { width: 100%; max-width: 320px; }

/* 결제 진행 모달 + 시간연장 다이얼로그 */
.modal-overlay {
  position: fixed; inset: 0; background: rgba(20,17,12,.55); z-index: 500;
  display: flex; align-items: center; justify-content: center; animation: fade .2s ease;
}
.modal-overlay[hidden] { display: none; }
.modal-box {
  background: var(--card); border-radius: calc(var(--radius-9) * var(--ui-radius-scale)); padding: 4vh 4vw;
  width: min(1000px, 92vw); min-height: 380px; box-shadow: 0 20px 60px rgba(0,0,0,.3);
  display: flex; flex-direction: column;
}
.modal-overlay-sm .modal-box { width: min(460px, 90vw); min-height: auto; text-align: center; padding: 5vh 5vw; }

.pay-modal-title { font-size: clamp(calc(19px * var(--ui-font-scale)), calc(2.4vw * var(--ui-font-scale)), calc(26px * var(--ui-font-scale))); font-weight: 800; margin-bottom: 16px; text-align: center; }
.pay-modal-body-grid { flex: 1; min-height: 0; display: grid; grid-template-columns: 300px 1fr; gap: 4vw; align-items: center; }
.pay-modal-guide-img { width: 100%; max-width: 300px; aspect-ratio: 1; border-radius: calc(var(--radius-8) * var(--ui-radius-scale)); background: var(--bg); object-fit: contain; display: flex; align-items: center; justify-content: center; color: var(--ink-soft); }
.pay-modal-guide-img svg { width: 42%; height: 42%; }
.pay-modal-status-area { text-align: center; }
.pay-modal-spinner { width: 44px; height: 44px; border: 4px solid var(--line); border-top-color: var(--pink); border-radius: 50%; animation: pmspin .9s linear infinite; margin: 16px auto; }
@keyframes pmspin { to { transform: rotate(360deg); } }
.pay-modal-price-grid { display: grid; grid-template-columns: 1fr auto; gap: var(--space-4) 0; font-weight: 700; margin-bottom: 16px; }
.pay-success-big { display: flex; flex-direction: column; align-items: center; gap: var(--space-6); padding: var(--space-4) 0 var(--space-1); }
.pay-success-check { width: 76px; height: 76px; animation: pscheckpop .45s cubic-bezier(.2,1.4,.4,1) both; }
.pay-success-big.no-anim .pay-success-check,
.pay-success-big.no-anim .pay-success-check circle,
.pay-success-big.no-anim .pay-success-check path,
.pay-success-big.no-anim .pay-success-label { animation: none; opacity: 1; stroke-dashoffset: 0; }
.pay-success-check svg { width: 100%; height: 100%; }
.pay-success-check circle { fill: none; stroke: var(--success); stroke-width: 3; stroke-dasharray: 151; stroke-dashoffset: 151; animation: pscheckring .5s ease-out .05s forwards; }
.pay-success-check path { fill: none; stroke: var(--success); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 36; stroke-dashoffset: 36; animation: pscheckmark .3s ease-out .4s forwards; }
.pay-success-label { font-size: clamp(calc(28px * var(--ui-font-scale)), calc(4vw * var(--ui-font-scale)), calc(40px * var(--ui-font-scale))); font-weight: 800; color: var(--ink); letter-spacing: -.01em; animation: psfadeup .4s ease-out .3s both; }
@keyframes pscheckpop { from { transform: scale(.6); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes pscheckring { to { stroke-dashoffset: 0; } }
@keyframes pscheckmark { to { stroke-dashoffset: 0; } }
@keyframes psfadeup { from { transform: translateY(8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }

/* 결제 실패 상태(2026-07-16) — 성공 아이콘(.pay-success-*)과 대칭되는 원+X 아이콘.
   같은 pop/ring/draw/fadeup 키프레임 재사용(톤 통일), 색상만 --danger-strong.
   구 .pay-modal-success 인라인 색상 재사용 대신 전용 클래스로 분리. */
.pay-fail-big { display: flex; flex-direction: column; align-items: center; gap: var(--space-6); padding: var(--space-4) 0 var(--space-1); }
.pay-fail-icon { width: 76px; height: 76px; animation: pscheckpop .45s cubic-bezier(.2,1.4,.4,1) both; }
.pay-fail-big.no-anim .pay-fail-icon,
.pay-fail-big.no-anim .pay-fail-icon circle,
.pay-fail-big.no-anim .pay-fail-icon path,
.pay-fail-big.no-anim .pay-fail-label { animation: none; opacity: 1; stroke-dashoffset: 0; }
.pay-fail-icon svg { width: 100%; height: 100%; }
.pay-fail-icon circle { fill: none; stroke: var(--danger-strong); stroke-width: 3; stroke-dasharray: 151; stroke-dashoffset: 151; animation: pscheckring .5s ease-out .05s forwards; }
.pay-fail-icon path { fill: none; stroke: var(--danger-strong); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 52; stroke-dashoffset: 52; animation: pscheckmark .3s ease-out .4s forwards; }
/* 모션 최소화 어드민 토글(2026-07-16, features.reduceMotion) — 결제 성공/실패 원+체크(X)의
   stroke-dasharray "그려지는" 애니메이션을 즉시 완성된 최종 모습(선이 다 그려진 상태)으로.
   기존 .no-anim(위, CFG.ui.animations.paymentCheck 전용 — 별개 어드민 설정)과 동일한 최종값을
   쓰되 완전히 별도 셀렉터라 서로 간섭하지 않는다(§app.js applyReduceMotion). 원 애니메이션
   (pscheckpop 팝업·psfadeup 라벨 페이드)은 이번 스코프 밖이라 그대로 둔다. */
body.reduce-motion .pay-success-check circle,
body.reduce-motion .pay-success-check path,
body.reduce-motion .pay-fail-icon circle,
body.reduce-motion .pay-fail-icon path {
  animation: none; stroke-dashoffset: 0;
}
.pay-fail-label { font-size: clamp(calc(18px * var(--ui-font-scale)), calc(2.2vw * var(--ui-font-scale)), calc(24px * var(--ui-font-scale))); font-weight: 800; color: var(--danger-strong); letter-spacing: -.01em; text-align: center; animation: psfadeup .4s ease-out .3s both; }
/* 직원 호출 상태 문구(2026-07-26) — pay-modal-actions 버튼들 아래, 결과(접수됨/실패)만
   짧게 알려주는 보조 텍스트. 기본 hidden, 버튼 클릭 후에만 채워짐. */
.pay-fail-callstaff-status { font-size: calc(13px * var(--ui-font-scale)); color: var(--ink-soft); text-align: center; margin-top: 10px; font-weight: 600; }
/* 매장 문의 연락처(2026-07-31, 라운드17 #2 — §app.js storeContactHtml). 바로 위 직원호출
   상태문구와 같은 톤/크기를 쓰되 색만 본문색으로 올린다 — 이건 상태 알림이 아니라 손님이
   **읽고 적어가야 하는 정보**라 --ink-soft(보조색)로는 대비가 부족하다. 매장이 값을
   안 넣으면 이 요소 자체가 렌더되지 않는다(빈 여백도 안 생김). */
.store-contact-line {
  font-size: calc(14px * var(--ui-font-scale)); color: var(--text); text-align: center;
  margin-top: 10px; font-weight: 700; user-select: text;   /* 손님이 번호를 길게 눌러 복사할 수 있게 */
}

/* 환불·취소 규정 사전 고지(2026-07-31, 라운드17 #4) — 결제하기 버튼 바로 아래, 결제 직전에
   마지막으로 읽는 자리. 고지문이라 결제금액보다 낮은 시각 위계로 두되(작은 글씨·70% 불투명),
   ★대비는 4.5:1을 지켜야 한다 — "고지했다"고 말하려면 읽을 수 있어야 한다(§HANDOFF 검증 수치).
   매장이 긴 문장을 넣어도 줄바꿈으로 흐르게 두고(max-height 없음) 결제 버튼을 밀지 않도록
   아래쪽에 배치. 비어 있으면 hidden이라 이 규칙 자체가 적용되지 않는다. */
.pay-policy-notice {
  font-size: calc(13px * var(--ui-font-scale)); line-height: 1.5; color: var(--text);
  opacity: .7; text-align: center; margin-top: 12px; white-space: pre-wrap; word-break: keep-all;
}

/* 마무리(감사합니다) 화면(2026-07-09) — 결제완료 화면과 같은 "간소하게 중앙에 크게" 원칙 */
#screen-thankyou { align-items: center; justify-content: center; }
.thankyou-block { display: flex; flex-direction: column; align-items: center; gap: var(--space-4); animation: psfadeup .4s ease-out both; }
.thankyou-label { font-size: var(--fs-h1); font-weight: 800; color: var(--ink); letter-spacing: -.01em; }
.thankyou-sub { font-size: clamp(calc(15px * var(--ui-font-scale)), calc(1.8vw * var(--ui-font-scale)), calc(19px * var(--ui-font-scale))); color: var(--ink-soft); }
.pay-modal-actions { display: flex; gap: var(--space-5); justify-content: center; margin-top: 20px; }
.pay-qr-canvas-modal { width: 260px; height: 260px; background: #fff; border-radius: calc(var(--radius-8) * var(--ui-radius-scale)); padding: var(--space-6); margin: 0 auto; display: flex; align-items: center; justify-content: center; box-shadow: 6px 6px 0 var(--accent-soft); border: 1.5px solid var(--line); }
.pay-qr-canvas-modal canvas { width: 100%; height: 100%; image-rendering: pixelated; }

.time-ext-title { font-size: calc(22px * var(--ui-font-scale)); font-weight: 800; margin-bottom: 12px; }
.time-ext-body { color: var(--ink-soft); font-weight: 600; margin-bottom: 20px; line-height: 1.5; }

/* ============================================================
   4) 촬영 (하루필름 PhotoShootBoothWithSC 3분할 — 좌:컷카운터 / 중앙:라이브뷰 / 우:미리보기)
   ============================================================ */
.cap-stage.cap-stage--3col {
  /* lp-body.box-container--outline(2026-07-16, 다른 진행화면과 동일 크롬 적용)가 이제 카드
     테두리+패딩을 이미 제공하므로 select-stage/pay-split과 동일하게 자체 padding은 없앰
     (예전엔 #screen-capture가 has-process가 아니라 이 padding이 유일한 여백원이었음). */
  flex: 1; min-height: 0; display: flex; align-items: stretch; justify-content: center;
  gap: 2vw; width: 100%; max-width: 1600px; margin: 0 auto;
}
/* 촬영 화면 = 2열(2026-08-07, 동주님 지시) ------------------------------------
   좌: 카메라(주인공) / 우: 레일 하나. 종전 3열(상태·카메라·미리보기)에서 좌측 상태패널을
   없앴다 — 실촬영에서 그 패널은 세로의 2/3가 비어 있었고, 그만큼 카메라가 작았다.
   손님이 촬영 순간 가장 원하는 것은 자기 모습이다. 이 화면은 거울이지 정보판이 아니다. */
.cap-side {
  background: var(--card); border: 1.5px solid var(--line);
  border-radius: calc(var(--radius-8) * var(--ui-radius-scale));
  flex: 0 0 var(--cap-side-pct, 33%); max-width: var(--cap-side-pct, 33%);
  display: flex; flex-direction: column; align-items: center;
  gap: var(--space-4); padding: var(--space-5) var(--space-5);
  min-height: 0; overflow: hidden;   /* 넘치면 못 보는 화면이라 스크롤을 아예 두지 않는다 */
}
/* 카운트다운 레일 — 카메라 **밖** 맨 위. 겹침 0px이어야 한다(실측 검증 대상).
   높이 고정: 카운트다운/READY/처리중이 교대해도 아래 요소들이 들썩이면 안 된다. */
.cap-side .cap-rail {
  position: relative; width: 100%; flex: 0 0 auto;
  /* 92→ (2026-08-07) READY 오버레이를 세로 3줄(제목·TOTAL·안내문)로 바꾸면서 높이를 키웠는데,
     그때 고친 것은 `.cap-rail`(명시도 0,1,0)이었고 **이 규칙(0,2,0)이 그걸 이겨** 아무 효과가
     없었다. 실측 computed height가 1080x1920에서 148px(=이 규칙의 상한)로 나와 죽은 규칙임이
     드러났다. 명시도를 못 이기는 수정은 "고쳤다"가 아니라 "안 고쳤다"이다 — 이 프로젝트에서
     반복된 유형이라(§.screen button min-height 사건) 값은 여기 한 곳에만 둔다. */
  height: clamp(104px, 17vh, 182px);
}
/* 남은 컷 — 우선순위가 낮으므로 카운트다운보다 확실히 작게. 위계를 크기로 말한다. */
.cap-side .cut-count { width: 100%; flex: 0 0 auto; text-align: center; }
.cap-side .cut-count-view { font-size: clamp(20px, 3.4vh, 34px); }
.cap-side .cut-count-label { font-size: calc(11px * var(--ui-font-scale)); opacity: .6; }
/* 직전 컷 큰 미리보기는 촬영 중 자리를 많이 먹는다 — 썸네일이 전부 보이는 게 더 중요하므로
   접어 둔다(요소는 남긴다 — app.js updatePreviewPhoto와 온스크린 에디터가 참조한다). */
.cap-side .preview-title { display: none; }
.cap-side .preview-photo { display: none; }
/* 찍은 사진 전부를 한눈에 — 2열 그리드, **스크롤 없음**.
   촬영 중에 썸네일을 드래그해 내려볼 손님은 없다. 스크롤은 없는 것과 같다. */
.cap-side .shot-thumbs {
  width: 100%; flex: 1 1 auto; min-height: 0; overflow: hidden;
  display: grid; grid-template-columns: repeat(3, 1fr);
  /* `grid-auto-rows: 1fr`이 핵심 — 행이 **남은 높이를 나눠 갖는다.** 장수가 몇이든
     컨테이너를 넘지 않는다. 칸 크기를 고정하면 촬영 장수(layout.shots+extraShots)가
     매장마다 달라 반드시 어딘가에서 넘친다. 넘치면 스크롤이 생기고, 촬영 중에 썸네일을
     드래그할 손님은 없으므로 그건 안 보이는 것과 같다. */
  grid-auto-rows: 1fr; gap: var(--space-3); align-content: stretch;
}
.cap-side .shot-thumbs .thumb {
  width: 100%; height: 100%; min-width: 0; min-height: 0; margin: 0;
  background-size: cover; background-position: center;
}
/* 비었을 때는 자리를 만들지 않는다(2026-08-07, gate_empty.py 검출) — 촬영 시작 직후
   썸네일이 아직 생성되기 전 274x216 빈 상자가 보였다. 손님은 빈 상자를 '고장났나' 또는
   '뭔가 빠졌나'로 읽는다. :empty면 접고, 첫 컷이 들어오는 순간 자연히 펼쳐진다. */
.cap-side .shot-thumbs:empty { display: none; }
.cap-side .pose-guide-section { width: 100%; flex: 0 0 auto; }
.cap-side #capture-cancel { width: 100%; flex: 0 0 auto; margin-top: auto; }

/* 포즈 가이드 선택 칩(2026-07-16 신설) — 좌측 상태패널 안, 기존 프레임/필터 칩 선택
   UX 패턴(.chip-row/.chip, §screen-frame/screen-result) 그대로 재사용. features.poseGuide
   꺼진 매장은 #pose-guide-section이 hidden이라 이 규칙들은 아무 영향 없음(무회귀). */
.pose-guide-section { width:100%; display:flex; flex-direction:column; gap:var(--space-3); }
.pose-guide-section .ctrl-label { margin:0; }
/* 좁은 상태패널(25%) 폭에 맞춰 칩 크기만 축소 — .chip 기본 클래스(min-width clamp(96~148px),
   선택 시 .on 스타일 등)는 그대로 상속, 여기선 크기만 좁힘. */
/* 좁은 패널이라 .chip 기본 최소폭(clamp 96~148px)은 풀지만, 접근성 하한 아래로는 못 내려간다
   (2026-07-31 — 이 규칙이 min-width를 직접 0으로 눌러 한 글자짜리 포즈명 칩이 30px로 측정됐다.
   flex-wrap 행이라 44px로 올려도 줄만 더 늘 뿐 가로 오버플로가 없음을 실측 확인). */
.pose-guide-chip-row .chip { min-width: var(--touch-floor); padding: var(--space-3) var(--space-4); font-size: calc(12px * var(--ui-font-scale)); }
/* 촬영 중앙열 = 상태 레일 + 라이브뷰(2026-08-06) ------------------------------
   레일은 **높이가 고정**이다. 카운트다운·READY·처리중이 번갈아 나타나도 카메라 크기가
   들썩이면 안 되기 때문 — 촬영 중에 프레이밍이 흔들리는 것이 제일 나쁘다.
   셋 다 레일 안에서 position:absolute로 겹쳐 두고 opacity로만 교대한다. */
.cap-center {
  flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4);
}
.cap-rail {
  position: relative; flex: 0 0 auto;
  /* 높이는 여기서 정하지 않는다 — `.cap-side .cap-rail`(명시도 0,2,0)이 이기므로 그쪽에만 둔다.
     여기에 값을 두면 "고친 것 같은데 안 바뀌는" 함정이 다시 생긴다(2026-08-07 실제로 겪음). */
  display: flex; align-items: center; justify-content: center;
  border-radius: calc(var(--radius-4) * var(--ui-radius-scale));
  background: var(--card); border: 1.5px solid var(--line);
  transition: background-color var(--dur-move) var(--ease-out), border-color var(--dur-move) var(--ease-out);
}
/* 이 레일은 세 상태(READY / 카운트다운 / 처리중)가 번갈아 쓰는 **한 자리**다. 셋 다 꺼져
   있는 순간(예: 한 컷을 찍고 다음 카운트다운이 시작되기 전)에는 카드 배경·테두리만 남아
   **아무 의미 없는 빈 검은 상자**가 된다 — 실촬영으로 확인했다. 자리는 유지하되(사라지면
   옆 썸네일이 위아래로 튄다) 상자처럼 보이지 않게 배경과 테두리를 지운다.
   ※`:has()`는 이 파일에서 이미 쓰는 기법이다(§body:has(#reprint-modal…)). */
.cap-rail:not(:has(.show)) { background: transparent; border-color: transparent; }
/* ★레일 안에서는 **세로 배치**(2026-08-07) ------------------------------------
   `.ready-overlay`의 `flex-direction:row`는 이 블록이 **라이브뷰 전체(폭 900px)를 덮던**
   시절의 값이다. 2026-08-06에 폭 270px짜리 레일로 이사하면서 방향을 그대로 뒀고, 그 결과
   실촬영에서 세 요소가 각 90px 칸에 갈려 들어가 전부 깨졌다:
     · "READY ACTION !" → "READY / ACTION / !" 세 줄로 쪼개짐
     · 안내문("결제하신 4컷보다 여유있게 7컷을 찍고…") → 폭 40px 칸에서 **한 글자씩 세로로**
   가로형 값이 세로/좁은 칸에서 축을 갈아타며 깨지는 이 함정은 이 파일의 재발 유형이다
   (§.print-col-message의 24%가 세로형에서 높이로 간 것, §X1의 .cap-side와 같은 계열).
   레일은 좁고 높이가 있는 칸이므로 세로가 맞다. */
.cap-rail .ready-overlay { flex-direction: column; gap: 2px; padding: 0 10px; }
.cap-rail .ready-heading { font-size: clamp(calc(18px * var(--ui-font-scale)), 3.2vh, calc(30px * var(--ui-font-scale))); color: var(--text); line-height: 1.1; white-space: nowrap; }
.cap-rail .ready-sub { font-size: calc(14px * var(--ui-font-scale)); color: var(--ink-soft); display:flex; align-items:center; gap:6px; white-space: nowrap; }
/* 컷 수는 이 화면의 **핵심 정보**다 — 부모의 --ink-soft를 그대로 물려받으면 읽히긴 해도
   흐리다. 형제인 .ready-heading이 이미 쓰는 --text로 맞춘다(§.ready-sub strong 주석). */
.cap-rail .ready-sub strong { color: var(--text); }
.cap-rail .ready-guide { font-size: calc(12px * var(--ui-font-scale)); color: var(--ink-soft); line-height: 1.35; word-break: keep-all; }
.cap-rail .processing-line1 { font-size: calc(16px * var(--ui-font-scale)); font-weight: 700; }
.cap-rail .processing-line2 { font-size: calc(13px * var(--ui-font-scale)); color: var(--ink-soft); }
.cap-rail .countdown-ring { width: clamp(56px, 11vh, 116px); height: clamp(56px, 11vh, 116px); }
.cam-holder {
  /* 좌우 패널이 --cap-side-pct(기본 25%)로 조정되므로, 중앙은 셋 중 하나만 고정폭이면
     계산이 안 맞음 — 나머지를 그냥 자동으로 채움(기본값 25%/25%일 때 예전 고정 46%와
     시각적으로 동일, calc()로 gap까지 빼는 방식보다 안전 — 실측 검증 완료). */
  flex: 1 1 auto; max-width: none;
  position: relative; background:#000; border-radius: calc(var(--radius-4) * var(--ui-radius-scale)); overflow:hidden;
  aspect-ratio: 4/3; max-height: 80vh;
  border: 10px solid var(--film); box-shadow: 0 18px 50px color-mix(in srgb, var(--text) 30%, transparent);
}
#cam { width:100%; height:100%; object-fit: cover; display:block; }
/* 브라우저 기본 미디어 컨트롤 억제(2026-08-14, #79 "촬영화면에서 마우스 올리면 상단 UI") —
   이 저장소의 <video> 넷(#cam · #coupon-qr-video · #print-preview-video · 손님폰 페이지)에는
   `controls` 속성이 없으므로 **지금은 아무 것도 안 바뀐다**(전수 확인). 나중에 누가 디버깅용
   으로 controls를 켜거나, 브라우저가 정책상 컨트롤을 강제로 붙이는 경우를 위한 보험이다.
   진짜 원인 후보는 브라우저 크롬(§app.js windowCoversScreen 주석) 쪽이라 이 규칙만으로
   해결됐다고 말하면 안 된다. */
video::-webkit-media-controls,
video::-webkit-media-controls-enclosure,
video::-webkit-media-controls-panel { display: none !important; }
video::-webkit-media-controls-start-playback-button { display: none !important; }
/* 포즈 가이드 오버레이(2026-07-16 신설) — 라이브뷰 위 반투명 실루엣. 실제 촬영은
   hal/camera.js Camera.capture()가 #cam 비디오 픽셀만 drawImage()로 읽으므로(DOM 스크린샷
   아님) 이 레이어는 화면표시 전용 — 최종 합성 캔버스(composer.js)·인화물에 절대 안 남는다.
   opacity 0.35~0.5 범위(DoD) 중간값. */
.pose-guide-overlay {
  position:absolute; inset:0; width:100%; height:100%;
  object-fit: contain; opacity: .4; pointer-events: none; z-index: 2;
}
.countdown {
  /* 2026-08-06 — 종전엔 `position:absolute; inset:0` + `font-size:30vh`로 라이브뷰를 통째로
     덮었다. 화면 높이의 30%짜리 숫자가 얼굴 정중앙에 떠 있어 포즈를 잡는 동안 자기 모습이
     안 보였다(동주님 지적). 이제 카메라 **위쪽 레일**에 놓인다 — 시선(카메라)에서 거의
     안 벗어나므로 숫자는 그대로 눈에 들어오고, 라이브뷰에는 포즈 가이드만 남는다.
     크기를 레일 높이에 맞춰 줄이되 글리프 자체는 굵고 크게 유지(팔 길이에서 읽혀야 한다). */
  position:absolute; inset:0; display:grid; place-items:center;
  /* 숫자 크기를 **자기가 앉은 레일**에 묶는다(2026-08-07, X1 검증 중 실측 발견).
     종전 `11vh`는 뷰포트 높이에 직접 매여 있어서, 레일 높이가 `clamp(88px,15vh,148px)`로
     **상한이 있는데** 숫자만 계속 커졌다 — 1080x1920 세로 매장에서 11vh = 211px가 148px
     레일을 뚫고 나와 바로 아래 "CUT n/N" 위에 겹쳐 그려졌다(실촬영으로 눈으로 확인).
     같은 셀에 겹쳐 그리는 링이 이미 `clamp(56px,11vh,116px)`로 상한을 갖고 있으므로
     **그 값을 그대로 쓴다** — 숫자와 링이 항상 같은 비율로 붙어 다니고, 116px 글리프는
     148px 레일 안에 들어온다. 가로형(720p)에선 11vh=79px로 종전과 값이 같다(무회귀). */
  font-family: var(--mono); font-size: calc(clamp(56px, 11vh, 116px) * var(--ui-font-scale)); font-weight: 700; color: var(--text);
  text-shadow: none; opacity: 0; pointer-events:none;
  /* §12-95 검증자 발견 — .pose-guide-overlay가 z-index:2를 갖는 반면 이 요소는 z-index가
     없어(auto) 형제 포지션 요소 중 명시 z-index가 이기는 CSS 스태킹 규칙상 포즈가이드가
     이 위를 덮어 가독성이 떨어졌다. 포즈가이드보다 항상 위에 그려지도록 명시. */
  z-index: 3;
}
.countdown.show { opacity: 1; }
/* 숫자·링을 같은 그리드 셀에 겹쳐 그림(둘 다 grid-area:1/1) — 펄스는 숫자에만, 링 드레인은
   링에만 걸려 서로 독립적으로 "병행" 재생된다(2026-07-16, §DoD 링 애니메이션). */
.countdown-ring, .countdown-num { grid-area: 1 / 1; }
/* line-height:1 — 글꼴 기본 줄상자는 글자 크기의 1.3배쯤이라(실측: font-size 116px일 때
   상자 155px) 숫자를 레일 높이에 맞춰 놔도 **상자만 8px 삐져나왔다**. 숫자는 grid에서
   place-items:center로 가운데 놓이므로 줄상자를 글자 크기에 맞춰도 보이는 크기·위치는
   그대로다(가로형 720p에서 79px → 79px, 무회귀). */
.countdown-num { position:relative; z-index:1; display:inline-block; line-height: 1; }
.countdown.pulse .countdown-num { animation: cdpulse 1s ease-out; }
@keyframes cdpulse { 0%{transform:scale(.5);opacity:0} 25%{opacity:1} 100%{transform:scale(1.5);opacity:0} }
/* 셔터 직전 마지막 숫자 강조(고객여정 임팩트 패키지 요구사항4, 2026-07-14) — n===1일 때만
   브랜드 핑크(--pink) + 살짝 더 크고 빠른 펄스로 차별화. 3클래스 결합(.countdown.pulse보다
   명시도 높음)이라 소스 순서와 무관하게 항상 이 규칙이 이김. */
.countdown.countdown-final { color: var(--pink); }
.countdown.countdown-final.pulse .countdown-num { animation: cdpulseFinal .85s var(--ease-spring); }
@keyframes cdpulseFinal { 0%{transform:scale(.4);opacity:0} 30%{opacity:1} 100%{transform:scale(1.7);opacity:0} }
/* 모션 최소화 어드민 토글(2026-07-16, features.reduceMotion) — 마지막 1초 핑크 펄스(scale+
   opacity 요동)를 꺼서 숫자가 .show(opacity:1)·.countdown-final(핑크) 기본 스타일 그대로
   요동 없이 정적으로만 보이게(§app.js applyReduceMotion). transform을 강제하지 않는 이유:
   다른 규칙이 transform을 안 건드리므로 animation만 꺼도 초기값(none)으로 되돌아간다. */
body.reduce-motion .countdown.countdown-final.pulse { animation: none; }
/* 원형 프로그레스 링(2026-07-16, UX 리서치: 능동적 진행표시가 수동적 숫자교체보다 체감속도가
   빠르게 느껴짐 — Cornell 로딩 연구 등) — stroke-dashoffset 기반, 매초 setNum() 호출 지점에서
   0(꽉 참)→둘레(빈 링)로 1초간 선형 드레인. 색상은 하드코딩 대신 기존 시맨틱 변수(--pink)만
   사용. 원(circle) 반지름 54 기준 둘레 = 2*π*54 ≈ 339.292 — 마크업의 r 값과 반드시 함께
   바뀌어야 함(§index.html .countdown-ring circle). */
.countdown-ring {
  width: calc(38vh * var(--ui-font-scale)); height: calc(38vh * var(--ui-font-scale));
  transform: rotate(-90deg);   /* 12시 방향에서 시작해 시계방향으로 드레인되도록 */
  overflow: visible;
}
.countdown-ring-track, .countdown-ring-fill { fill: none; stroke-width: 6; }
.countdown-ring-track { stroke: color-mix(in srgb, #fff 22%, transparent); }
.countdown-ring-fill {
  stroke: var(--pink); stroke-linecap: round;
  stroke-dasharray: 339.292; stroke-dashoffset: 0;   /* 기본 = 꽉 찬 상태(애니메이션 시작점) */
}
.countdown-ring-fill.countdown-ring-anim { animation: countdown-ring-drain 1s linear; }
@keyframes countdown-ring-drain { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 339.292; } }
@media (prefers-reduced-motion: reduce) {
  .countdown-ring-fill.countdown-ring-anim { animation: none; stroke-dashoffset: 0; }
}
/* 모션 최소화 어드민 토글(2026-07-16) — 위 prefers-reduced-motion과 동일 처리를
   <body class="reduce-motion">에도(§app.js applyReduceMotion, 기존 print-qr-skeleton과
   동일 패턴). */
body.reduce-motion .countdown-ring-fill.countdown-ring-anim { animation: none; stroke-dashoffset: 0; }
.flash { position:absolute; inset:0; background:#fff; opacity:0; pointer-events:none; }
.flash.go { animation: flashAnim .5s ease-out; }
@keyframes flashAnim { 0%{opacity:0} 8%{opacity:.95} 100%{opacity:0} }
/* 게임화 운세 프레임(2026-07-14 신설, 끝없는 품질개선 루프 14회차) — 카운트다운 중 문구가
   슬롯머신처럼 빠르게 순환(spin)하다가 셔터 순간 정확히 하나로 고정(locked)된다. */
.fortune-spin {
  position:absolute; top:6%; left:50%; transform:translateX(-50%);
  max-width:86%; padding:var(--space-4) 22px; border-radius:calc(var(--radius-pill) * var(--ui-radius-scale));
  background:rgba(20,17,12,.72); color:#fff; text-align:center; white-space:nowrap;
  overflow:hidden; text-overflow:ellipsis;
  font-family: var(--mono); font-weight:700; font-size: calc(16px * var(--ui-font-scale));
  text-shadow:0 2px 10px rgba(0,0,0,.4); pointer-events:none; z-index:3;
}
.fortune-spin.spin { filter: blur(.6px); animation: fortuneShake .09s linear infinite; }
@keyframes fortuneShake { 0%{transform:translateX(-50%) translateY(0)} 50%{transform:translateX(-50%) translateY(-2px)} 100%{transform:translateX(-50%) translateY(0)} }
.fortune-spin.locked { background:rgba(233,30,99,.88); animation: fortuneLock .4s ease-out; }
@keyframes fortuneLock { 0%{transform:translateX(-50%) scale(1.15)} 60%{transform:translateX(-50%) scale(.96)} 100%{transform:translateX(-50%) scale(1)} }
/* 완성본 확인화면 재확인 칩(고객여정 임팩트 패키지 요구사항1, 2026-07-14) — 위 fortune-spin.locked
   와 같은 배지 모양·색을 그대로 쓰되, 카메라 라이브뷰 위 절대좌표 오버레이가 아니라 확인화면의
   정적 세로 레이아웃 안에 자연스럽게 놓이도록 위치/트랜스폼만 재정의(3클래스 결합으로 상위
   .fortune-spin.locked보다 명시도를 높여 안전하게 덮어씀). */
.fortune-spin.locked.preview-fortune-chip {
  position: static; transform: none; animation: none; margin: 0 auto; white-space: normal;
}
/* 레어도 가챠(2026-07-15 신설, 게이미피케이션 확장 패키지) — 촬영 셔터 순간 확정된 운세
   문구의 등급(§app.js startFortuneSpin)에 따라 배지를 시각적으로 차별화. common(기본)은
   위 .locked 스타일 그대로(무회귀) — rare/epic 클래스가 추가로 붙었을 때만 아래가 적용된다.
   #fortune-spin(촬영화면)·#preview-fortune-chip(확인화면 재확인 배지) 양쪽에 동일 적용. */
.fortune-spin.locked.fortune-rare {
  background: rgba(38,44,58,.88);
  box-shadow: 0 0 0 2px #c7d2e0, 0 4px 22px rgba(150,165,190,.4);
}
.fortune-spin.locked.fortune-epic {
  background: linear-gradient(90deg, rgba(233,30,99,.88), rgba(123,58,180,.88));
  box-shadow: 0 0 0 2px #f0c04b, 0 0 18px rgba(240,192,75,.65);
  animation: fortuneLock .4s ease-out, fortuneEpicShimmer 1.6s ease-in-out .4s infinite;
}
@keyframes fortuneEpicShimmer {
  0%, 100% { box-shadow: 0 0 0 2px #f0c04b, 0 0 10px rgba(240,192,75,.4); }
  50%      { box-shadow: 0 0 0 3px #ffdd85, 0 0 26px rgba(240,192,75,.9); }
}
.cam-msg { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  text-align:center; padding: 24px; color: #fff; font-size: calc(20px * var(--ui-font-scale)); font-weight:600; pointer-events:none; z-index: 4; }
/* 에러 메시지가 있을 때만 어둡게 (평소엔 카메라를 가리지 않음) */
.cam-msg:not(:empty) { background: rgba(20,17,12,.85); pointer-events:auto; }
.rec-dot {
  position:absolute; top:16px; left:16px; background: var(--pink); color:var(--on-pink);
  font-family: var(--mono); font-weight:700; font-size: calc(14px * var(--ui-font-scale)); padding:var(--space-2) var(--space-5); border-radius:calc(var(--radius-3) * var(--ui-radius-scale));
  display:flex; align-items:center; gap:7px; letter-spacing:.15em; z-index:3;
}
.rec-dot .rd { width:9px; height:9px; border-radius:50%; background:#fff; animation: recblink 1.2s steps(2,start) infinite; }

/* 실시간 피부보정 토글(2026-07-12, 경쟁사 벤치마킹) — 라이브뷰 좌하단, rec-dot(좌상단)과 겹치지 않게 */
/* 진짜 굴절 유리 재질(2026-07-23, 경쟁사/디자인 트렌드 리서치 — Apple Liquid Glass 참고,
   "굴절·왜곡하는 실제 재질, 장식 아님"). 기존엔 단순 반투명 배경+약한 blur(정적 광택)였음 —
   saturate()를 더해 밑에서 움직이는 라이브캠 화면이 이 칩을 통과할 때 색이 살짝 진해 보이는
   렌즈 효과를 내고, 상단 하이라이트(inset box-shadow)로 얇은 유리 테두리가 빛을 받는 느낌을
   준다. 배경 불투명도(.55)는 WCAG 대비 유지를 위해 그대로 유지 — 화려함보다 가독성 우선
   (§design 원리 교과서 원칙과 동일), saturate/보더/하이라이트만 추가한 것도 이 때문. */
.beautify-toggle {
  position:absolute; bottom:16px; left:16px; z-index:3;
  background: rgba(20,17,12,.55);
  backdrop-filter: blur(10px) saturate(1.7);
  -webkit-backdrop-filter: blur(10px) saturate(1.7);
  border: 1px solid rgba(255,255,255,.14);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 2px 10px rgba(0,0,0,.28);
  border-radius: calc(var(--radius-8) * var(--ui-radius-scale)); padding:var(--space-3) var(--space-4); display:flex; flex-direction:column; gap:var(--space-2);
}
.beautify-label { color:#fff; font-size: calc(11px * var(--ui-font-scale)); font-weight:700; letter-spacing:.08em; opacity:.85; }
.beautify-levels { display:flex; gap:var(--space-1); }
.beautify-levels button {
  border:1px solid rgba(255,255,255,.35); background:transparent; color:#fff;
  font-size: calc(12px * var(--ui-font-scale)); font-weight:700; padding:5px var(--space-4); border-radius:calc(var(--radius-pill) * var(--ui-radius-scale)); cursor:pointer;
}
.beautify-levels button.on { background: var(--pink); border-color: var(--pink); color: var(--on-pink); }
@keyframes recblink { 50%{opacity:.3} }

/* READY ACTION! 오버레이 (하루필름 그대로 — 5초 표시) */
.ready-overlay {
  /* 레일로 이사(2026-08-06) — 라이브뷰를 덮던 어두운 그라디언트 배경을 없앤다. 손님이
     "이제 찍는다"를 아는 동안에도 자기 모습이 계속 보여야 포즈를 잡을 수 있다. */
  position:absolute; inset:0; z-index:5; display:flex; flex-direction:row;
  align-items:center; justify-content:center; gap:var(--space-6); text-align:center;
  background: none;
  opacity:0; pointer-events:none; transition: opacity var(--dur-move) var(--ease-out);
}
.ready-overlay.show { opacity:1; pointer-events:auto; }
.ready-heading {
  font-family: var(--mono); font-weight:800; font-size: clamp(calc(26px * var(--ui-font-scale)), calc(4.6vw * var(--ui-font-scale)), calc(48px * var(--ui-font-scale)));
  color:#fff; letter-spacing:.06em; animation: readyPulse 1s ease-out infinite alternate;
}
@keyframes readyPulse { from{ transform:scale(1);} to{ transform:scale(1.04);} }
.ready-sub {
  font-family: var(--mono); font-weight:700; font-size: clamp(calc(15px * var(--ui-font-scale)), calc(2.2vw * var(--ui-font-scale)), calc(20px * var(--ui-font-scale)));
  color: var(--pink); letter-spacing:.12em; display:flex; gap:var(--space-3); align-items:baseline;
}
/* color를 **여기서 지정하지 않는다**(2026-08-09 감사). 이 규칙은 READY 오버레이가
   카메라 위 어두운 배경에 있던 시절의 하드코딩 흰색이었다. 2026-08-06/07에 이 블록이
   `.cap-rail`(background: var(--card) = 밝은 종이색)로 이사하면서 형제들(.ready-heading·
   .ready-guide·.processing-line2)은 테마 변수로 다시 칠해졌는데 이 <strong>만 빠졌다.
   부모 `.cap-rail .ready-sub`가 --ink-soft를 줘도 <strong>은 자기 규칙이 직접 매치돼
   상속을 이긴다. 실측: 기본 프리셋에서 글자 rgb(255,255,255) / 배경 rgb(251,247,238)
   = **대비 1.07:1** — 내장 밝은 프리셋 6종 전부에서 "TOTAL  CUT"으로 숫자만 사라진다.
   손님이 몇 컷 찍히는지 알 수 없다(운영 중인 다크 테마에서만 우연히 멀쩡했다).
   상속을 그대로 받게 두면 어두운 배경에서도 밝은 배경에서도 부모가 정한 색을 따른다. */
.ready-sub strong { font-size: 1.6em; }
.ready-guide { color: rgba(255,255,255,.75); font-size: var(--fs-caption); }

/* 처리중 오버레이(촬영 완료 후) */
.processing-overlay {
  /* 레일로 이사(2026-08-06) — 카메라를 가리던 불투명 배경 제거. 가로 배치로 바꿔
     레일 한 줄에 스피너와 문구가 나란히 들어간다. */
  position:absolute; inset:0; z-index:6; display:flex; flex-direction:row;
  align-items:center; justify-content:center; gap:var(--space-5);
  background: none;
  color: var(--text); opacity:0; pointer-events:none; transition: opacity var(--dur-quick) var(--ease-out);
}
.processing-overlay.show { opacity:1; pointer-events:auto; }
.processing-spinner {
  width:40px; height:40px; border-radius:50%;
  border: 4px solid rgba(255,255,255,.25); border-top-color:#fff;
  animation: procspin .8s linear infinite;
}
@keyframes procspin { to { transform: rotate(360deg); } }
.processing-line1 { font-weight:700; font-size: clamp(calc(16px * var(--ui-font-scale)), calc(2vw * var(--ui-font-scale)), calc(20px * var(--ui-font-scale))); }
.processing-line2 { font-size: clamp(calc(13px * var(--ui-font-scale)), calc(1.6vw * var(--ui-font-scale)), calc(15px * var(--ui-font-scale))); color: rgba(255,255,255,.7); }

/* 폰 사진 업로드 촬영대체(2026-07-17 신설) — features.phonePhotoUpload 켜진 매장만
   §app.js gotoCapture()가 노출. .processing-overlay/.ready-overlay와 동일하게 .cam-holder
   전체를 덮는 절대배치 레이어지만, 두 상태 모두 값이 변할 때만 등장하는 그 둘과 달리
   화면 진입 즉시(카메라 시작 전) 나타나야 해서 opacity 트랜지션 없이 단순 hidden 토글만
   쓴다(§beautify-toggle과 동일한 단순 표시/숨김 패턴). z-index는 기존 최댓값(processing-
   overlay:6)보다 한 단계 높여 두 선택 화면이 항상 최상단에 오도록 한다. */
.capture-mode-choice, .phone-upload-panel {
  position:absolute; inset:0; z-index:7; display:flex; flex-direction:column;
  align-items:center; justify-content:center; gap:var(--space-7); text-align:center;
  padding: var(--space-7); background: color-mix(in srgb, var(--film) 94%, transparent); color:#fff;
}
/* 이 두 패널은 **테마와 무관한 고정 어두운 배경**(--film 94%)을 쓴다. 그런데 안의
   ghost 버튼은 `color: var(--text)`라 컨테이너의 color:#fff를 덮어써서, 밝은 프리셋에서는
   검정 글자가 검정 배경 위에 놓인다 — 실측 기본 coral-paper 1.03:1, minimal-white 1.08:1.
   테두리도 --line-strong이라 같이 사라져 버튼이 통째로 안 보인다.
   옆의 카메라 버튼은 .primary-btn이라 멀쩡해서 **손님 눈에는 선택지가 하나뿐인 화면**이 된다.
   더 나쁜 자리는 phone-upload-panel 쪽이다 — QR 업로드를 기다리다 카메라 촬영으로
   돌아가는 유일한 탈출구가 그 안 보이는 버튼이다(막히면 세션을 버리는 수밖에 없다).
   고정 팔레트를 쓰는 컨테이너 안에서는 버튼도 그 팔레트를 따르게 한다
   (같은 파일 .beautify-levels가 이미 쓰는 방식). */
.capture-mode-choice .ghost-btn, .phone-upload-panel .ghost-btn {
  color: #fff; border-color: rgba(255, 255, 255, .5);
}
/* .modal-overlay[hidden]/.lock-overlay[hidden]과 동일한 이유(§516, §1210줄) — 같은 클래스에
   display:flex 선언이 있어 UA기본 [hidden]→none을 덮어씀. phonePhotoUpload 꺼진 매장(기본값)
   에서도 두 레이어가 hidden 상태로 계속 렌더돼 카메라뷰를 가려버리는 회귀 방지. */
.capture-mode-choice[hidden], .phone-upload-panel[hidden] { display: none; }
.cmc-heading, .pup-heading {
  font-weight:800; font-size: clamp(calc(18px * var(--ui-font-scale)), calc(2.4vw * var(--ui-font-scale)), calc(24px * var(--ui-font-scale)));
  letter-spacing:-.01em;
}
.cmc-options { display:flex; flex-direction:column; gap: var(--space-5); width: min(86%, 360px); }
.cmc-opt { width:100%; }
.pup-qr { background:#fff; padding: var(--space-5); border-radius: calc(var(--radius-4) * var(--ui-radius-scale)); line-height:0; }
.pup-qr canvas { display:block; width: clamp(160px, 22vw, 240px); height: clamp(160px, 22vw, 240px); }
.pup-status { font-family: var(--mono); font-size: var(--fs-caption); letter-spacing:.08em; color: rgba(255,255,255,.75); }

/* 좌측 컷카운터(MINUS_COUNT 방식 — 남은컷/전체) */
.cut-count { display:flex; flex-direction:column; align-items:center; gap:var(--space-1); }
.cut-count-label {
  font-family: var(--mono); font-size: var(--fs-caption); font-weight:700;
  letter-spacing:.25em; color: var(--ink-soft);
}
.cut-count-view {
  font-family: var(--mono); font-weight:800; line-height:1;
  font-size: clamp(calc(40px * var(--ui-font-scale)), calc(6vw * var(--ui-font-scale)), calc(72px * var(--ui-font-scale))); display:flex; align-items:baseline; gap:var(--space-1);
}
.cut-num { color: var(--text); }
.cut-sep { color: var(--ink-soft); font-size:.6em; margin:0 2px; }
.cut-den { color: var(--ink-soft); font-size:.6em; }

/* 우측 PreviewPhoto(직전 컷 크게) + 썸네일 스트립(보조) */
.cap-preview-panel { justify-content:flex-start; }
.preview-title {
  font-family: var(--mono); font-weight:700; letter-spacing:.25em; font-size: calc(14px * var(--ui-font-scale));
  color: var(--ink-soft); align-self:flex-start;
}
.preview-photo {
  width: 100%; aspect-ratio: 4/3; border-radius: calc(var(--radius-4) * var(--ui-radius-scale)); overflow:hidden;
  background: var(--card); border: 1.5px solid var(--line);
  background-size: cover; background-position:center;
  display:flex; align-items:center; justify-content:center;
  color: var(--ink-soft); font-size: calc(13px * var(--ui-font-scale)); text-align:center; padding: var(--space-4);
}
.preview-photo.empty::after { content:'첫 컷을 기다리는 중…'; }
.shot-thumbs { display:flex; flex-direction:column; gap: var(--space-3); width:100%; max-height: 30vh; overflow-y:auto; }
.thumb {
  width: 100%; aspect-ratio: 4/3; border-radius: calc(var(--radius-3) * var(--ui-radius-scale)); background: var(--card);
  display:flex; align-items:center; justify-content:center; color: var(--ink-soft);
  font-family: var(--mono); font-size: calc(16px * var(--ui-font-scale)); font-weight:700; background-size: cover; background-position: center;
  border: 1.5px solid var(--line);
}
/* select-cell.on과 같은 이유(사진 위 저채도 그림자 가독성) — 이미 있던 불투명 border(3px)에
   더해 바깥쪽 outline 링 + 브랜드 시그니처 그림자를 보조로 추가. */
.thumb.filled {
  border: 3px solid var(--pink);
  outline: 2px solid var(--pink);
  outline-offset: 2px;
  box-shadow: var(--shadow-sig-sm);
  animation: thumbPop .38s var(--ease-spring) both;
}
/* 촬영 완료 썸네일 팝(2026-07-26, 경쟁사/트렌드 리서치 라운드8 finding 4) — app.js
   runCaptureSequence()가 캡처 직후 thumb.classList.add('filled')만 하고(§app.js
   2330줄 부근) 그 뒤 await wait(1200)이라는 죽은 시간이 있었음. JS 타이밍·카메라·
   합성 로직은 전혀 안 건드리고 이미 있는 클래스 토글에 순수 CSS 애니메이션만 얹는다
   — 380ms로 wait(1200)보다 훨씬 짧아 다음 카운트다운 시작을 침범하지 않음. */
@keyframes thumbPop {
  0% { transform: scale(.7); opacity: 0; }
  60% { transform: scale(1.05); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .thumb.filled { animation: none; }
}
body.reduce-motion .thumb.filled { animation: none; }

/* 전역 토스트 — 시스템 안내(매장 테마 무관 고정 팔레트, 간결한 화이트/블랙) */
.toast {
  position: fixed; top: 24px; left: 50%; transform: translateX(-50%) translateY(-16px);
  background: var(--sys-fg); color: var(--sys-bg); padding: var(--space-6) 26px; border-radius: calc(var(--radius-8) * var(--ui-radius-scale));
  font-size: clamp(calc(14px * var(--ui-font-scale)), calc(1.6vw * var(--ui-font-scale)), calc(18px * var(--ui-font-scale))); font-weight: 700; z-index: 1200; max-width: 84vw;
  text-align: center; white-space: pre-line; opacity: 0; pointer-events: none;
  transition: opacity var(--dur-move) var(--ease-out), transform var(--dur-move) var(--ease-out); box-shadow: 0 10px 30px rgba(0,0,0,.28);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

/* 단계 타이머(하루필름 Timer 컴포넌트 참고) */
.step-timer { font-family: var(--mono); font-weight: 700; color: var(--pink-text); margin-left: 6px; }

/* ============================================================
   5) 사진 선택 (하루필름 PhotoSelector 그대로 — 좌:갤러리 / 우:미리보기+편집버튼)
   ============================================================ */
.select-stage { flex:1; display:flex; gap: 3.5vw; min-height: 0; }
/* 좌우 비율 1:1 → 1.6:1 (2026-08-07, 동주님: "필요없는 여백 최소화해서 필요한 게 잘 보이도록")
   ────────────────────────────────────────────────────────────────────────
   격자 샘플링으로 재니 이 화면 본문의 **18%가 하나의 빈 사각형**(280x429)이었다 — 우측 열의
   오른쪽 절반이다. 우측에 있는 것은 세로로 긴 인화물 미리보기라 **높이에 막혀 있고 폭은
   남는다**. 즉 우측에 폭을 더 줘도 미리보기는 1px도 안 커지고, 그 폭은 그대로 빈 공간이 된다.
   반대로 좌측 사진 갤러리는 폭이 곧 사진 크기다(auto-fit minmax). 그래서 남는 폭을 좌측으로
   옮긴다 — 빈 공간이 줄어드는 동시에 손님이 고를 사진이 커진다. 같은 조치가 두 문제를 푼다.
   ※`overflow-y:auto`는 그대로 둔다 — 컷 수가 많은 상품에서 여전히 넘칠 수 있고, 그 경우
   §fitSelectGrid가 셀을 줄여 대응한다(그 로직은 폭이 늘어도 그대로 동작한다). */
.select-left { flex: 1.6; min-height: 0; overflow-y: auto; }
/* ★세로형 되돌리기(2026-08-07 2차) — 위 1.6은 **가로 2단 배치를 전제로** 남는 폭을 갤러리로
   옮긴 값이다. 세로형에서는 `.select-stage`가 flex-direction:column이라 이 값이 폭이 아니라
   **높이**로 간다. 실측(1080x1920): 갤러리는 1px도 안 커지고 손님 확인용 인화물 미리보기만
   면적 46~48% 줄었다(148x445 ← flex:1이면 201x603). 정반대 효과다.
   가로형 값이 세로형에서 축을 갈아타는 이 함정은 이 파일의 재발 유형이다(§.print-col-message
   24%, §X1 .cap-side와 같은 계열) — 그래서 이번에도 세로형에서 명시적으로 되돌린다. */
@media (max-aspect-ratio: 1/1) {
  .select-left { flex: 1; }
}
.select-right { flex: 1; display:flex; flex-direction:column; gap: var(--space-6); min-height: 0; min-width: 0; }
.select-right .edit-preview { flex: 1; min-height: 0; }
.frame-preview-control { display:flex; gap: var(--space-4); justify-content:center; flex-wrap:wrap; }
.edit-btn {
  background: var(--card); border: 1.5px solid var(--line); border-radius: calc(var(--radius-7) * var(--ui-radius-scale));
  padding: var(--space-4) var(--space-7); display:flex; flex-direction:column; align-items:center; gap: 3px;
  cursor: pointer; color: var(--text); font-size: calc(12px * var(--ui-font-scale)); font-weight: 700;
  --own-w: 78px; --touch-min-w: max(var(--own-w), var(--touch-floor)); min-width: var(--touch-min-w); /* §터치타깃 하한 주석 */
  transition: border-color var(--dur-quick) var(--ease-out), transform var(--dur-move) var(--ease-spring);
}
.edit-btn .ic { font-size: calc(20px * var(--ui-font-scale)); }
.edit-btn .ic svg { width: 20px; height: 20px; vertical-align: middle; }
.edit-btn:hover { border-color: var(--text); }
.edit-btn:active { transform: translateY(1px); }
/* .edit-btn 라벨은 실제 한글 단어(반전/삭제/초기화 등)라 아이콘과 달리 4.5:1 텍스트 기준 적용
   — border/그림자는 브랜드 컬러(--pink) 유지, 글자색만 --pink-text로 가독성 확보. */
.edit-btn.active { border-color: var(--pink); color: var(--pink-text); box-shadow: var(--shadow-sig-sm); }
.edit-btn.delete-btn.active { border-color: var(--danger-strong); color: var(--danger-strong); }
.ctrl-hint { font-size: calc(11px * var(--ui-font-scale)); text-transform: none; letter-spacing: 0; color: var(--ink-soft); font-weight: 500; margin-left: 6px; }

.select-grid {
  flex:1; min-height: 0; display:grid;
  grid-template-columns: repeat(auto-fit, minmax(calc(var(--select-grid-minw, 160px) * var(--select-fit, 1)), 1fr));
  gap: 2vh 2vw; align-content:center; max-width: 1100px; margin: 0 auto; width:100%; padding: 2vh 0;
}
/* 사진선택 갤러리 세로 압축(2026-08-07, §fitSelectGrid) ------------------------
   실측(1280x720, 기본 매장 = 촬영 7컷): `.select-left` clientHeight 424 / scrollHeight 760.
   3열 3행이 되면서 **7장 중 4장이 화면 밖**이었다. 스크롤은 되지만 터치 키오스크엔 스크롤바가
   없어 손님은 사진이 더 있다는 사실 자체를 모른다 — 뒤에 있는 4장은 없는 것과 같다.
   ★매장이 정한 `--select-grid-minw`를 덮어쓰지 않고 **곱한다**(--select-fit). 매장이 크게
   잡았으면 크게, 작게 잡았으면 작게 — 상대적인 선호는 그대로 남는다.
   ★단계값은 눈대중이 아니라 후보표 실측(`tools/probes/select_grid_table.js`)에서 골랐다:
     7장 minw160=3열3행(760) · **125=4열2행(424 딱 맞음)** / 9장은 125로도 3행(559) → **90=5열2행**
     / 12장은 90으로도 3행 438 = **gap(14) 하나 차이로 초과** → 마지막 단계만 gap을 함께 조인다.
   ★들어맞는 매장·규격에서는 클래스가 하나도 안 붙는다 = 화면이 1px도 안 변한다(무회귀). */
.select-grid.is-fit-1 { --select-fit: .78; }   /* 160 → 125 */
.select-grid.is-fit-2 { --select-fit: .56; }   /* 160 → 90  */
.select-grid.is-fit-3 { --select-fit: .5; gap: .8vh 1vw; }

/* 사진선택 대형 카운터(2026-08-06, 포토이즘 레퍼런스) ---------------------------
   레퍼런스는 안내문(≈20px) 대비 카운터(≈45~60px)를 3배로 띄워 **문장이 아니라 숫자
   크기로** 남은 장수를 말한다. 서서 보는 거리에서 제목을 읽지 않아도 상태가 들어온다.
   숫자는 고정폭(tabular-nums)이라 0→1→2로 바뀌어도 자리가 흔들리지 않는다. */
.select-counter {
  display: flex; align-items: baseline; gap: 4px;
  font-family: var(--mono); font-weight: 800; line-height: 1;
  font-size: clamp(calc(30px * var(--ui-font-scale)), 5.2vh, calc(56px * var(--ui-font-scale)));
  font-variant-numeric: tabular-nums; letter-spacing: .02em; color: var(--text);
  margin-top: var(--space-2);
}
.select-counter .sc-sep { color: var(--ink-soft); font-weight: 400; margin: 0 2px; }
#select-counter-d { color: var(--ink-soft); }
.select-cell {
  position: relative; padding:0; border: 3px solid transparent; border-radius: 0; overflow: hidden;   /* 사진 = 직각 */
  cursor: pointer; background: var(--card); aspect-ratio: 3/4; transition: border-color var(--dur-quick) var(--ease-out), transform var(--dur-move) var(--ease-spring);
}
.select-cell img { width:100%; height:100%; object-fit:cover; display:block; }
.select-cell:active { transform: translateY(2px); }
/* 접근성 긴급수정(2026-07-14) — 다른 selected 상태(.chip.on 등)와 같은 브랜드 시그니처
   그림자를 추가하되, 사진 위에 얹히는 셀이라 저채도(--accent-soft) 그림자만으로는 사진 색에
   묻혀 안 보일 수 있음 — 항상 불투명한 outline 링을 셀 바깥(그리드 여백)에 추가로 둘러 어떤
   사진에서도 선택 여부가 또렷이 보이게 하고, 시그니처 그림자는 보조로만 얹는다. */
.select-cell.on {
  border-color: var(--pink);
  outline: 3px solid var(--pink);
  outline-offset: 2px;
  box-shadow: var(--shadow-sig-sm);
}
.select-badge {
  position:absolute; top:8px; right:8px; min-width:26px; height:26px; border-radius:50%;
  background: var(--pink); color:var(--on-pink); font-family: var(--mono); font-weight:800; font-size: calc(14px * var(--ui-font-scale));
  display:flex; align-items:center; justify-content:center; padding: 0 var(--space-1);
  opacity:0; transform: scale(.6); transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-move) var(--ease-spring);
}
.select-cell.on .select-badge { opacity:1; transform: scale(1); }

/* ============================================================
   6/7) 프레임 디자인 · 결과 디자인
   ============================================================ */
.edit-stage { flex:1; display:flex; gap: 3.5vw; min-height: 0; }
.edit-preview { flex: var(--edit-preview-flex, 1.1); display:flex; align-items:center; justify-content:center; min-height: 0; min-width: 0; position: relative; }
.preview-hint {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  text-align: center; padding: var(--space-8); color: #fff; background: rgba(20,17,12,.55);
  font-size: var(--fs-body); font-weight: 700; pointer-events: none; border-radius: calc(var(--radius-6) * var(--ui-radius-scale));
}
.edit-preview canvas, .final-preview canvas { border-radius: 0; }   /* 인화물 = 직각 */
.edit-controls { flex: 1; display:flex; flex-direction:column; gap: var(--space-4); overflow-y:auto; padding-right: var(--space-2); }
/* 카테고리 섹션(2026-08-07, 큐 §B2) — 결과화면의 필터/스티커/조정을 탭으로 한 번에 하나만
   보여주려면 각 묶음이 통째로 감춰지는 하나의 요소여야 한다(§app.js CHIP_GROUPS cats[].wrap).
   ★위 .edit-controls가 flex column + gap이라 자식을 그냥 감싸면 그 안쪽 간격이 사라진다 —
     같은 방향·같은 gap을 물려줘 감싸기 전과 픽셀로 같게 만든다.
   ★[hidden]을 다시 적는 이유: display:flex(작성자 스타일)는 UA의 display:none을 이긴다.
     빠뜨리면 hidden을 걸어도 섹션이 그대로 보여 페이저가 조용히 무력화된다
     (§.chip-row[hidden]·§#layout-list .layout-card[hidden]과 같은 함정, 실제로 겪은 자리다). */
.ctrl-section { display:flex; flex-direction:column; gap: var(--space-4); }
.ctrl-section[hidden] { display: none; }
.ctrl-label { margin: 10px 0 2px; font-family: var(--mono); font-size: calc(13px * var(--ui-font-scale)); letter-spacing:.15em;
  text-transform: uppercase; color: var(--ink-soft); font-weight: 600; }
/* 선택지 개수 배지(2026-07-13, 경쟁사 실사 조사 참고) — 헤더 라벨 옆 "N종" */
.ctrl-count { text-transform:none; letter-spacing:normal; font-weight:800; margin-left:6px;
  padding:1px 7px; border-radius:calc(var(--radius-pill) * var(--ui-radius-scale)); background: var(--card); border:1px solid var(--line);
  color: var(--pink-text); font-size: calc(11px * var(--ui-font-scale)); vertical-align:middle; }
.chip-row { display:flex; flex-wrap:wrap; gap: var(--space-4); }
/* ★[hidden] 명시 재확인(2026-08-07, 큐 §B) — `display:flex`는 **작성자 규칙**이라 UA 스타일시트의
   `[hidden]{display:none}`을 이긴다. 이게 없으면 카테고리 탭·페이저가 칩을 숨겨도 그대로 보여
   기능이 통째로 무력화된다(§C의 `#layout-list .layout-card[hidden]`에서 똑같이 겪은 자리). */
.chip-row[hidden] { display: none; }
.chip[hidden] { display: none; }
.chip {
  background: var(--card); border: 1.5px solid var(--line); border-radius: calc(var(--radius-5) * var(--ui-radius-scale));
  /* 선택칩 확대(2026-07-15, 매장 실사 지적 — "작고 밀집돼 보인다") — padding/min-width를
     아래 .chip-prev/.chip-color 확대(62px 고정 → clamp(84~128px))에 비례해 함께 키움.
     renderFrameChips()/buildFilterList()(app.js)의 마크업·로직은 그대로. */
  padding: var(--space-4); cursor: pointer; color: var(--text); display:flex; flex-direction:column;
  align-items:center; gap:var(--space-2); font-size: calc(13px * var(--ui-font-scale)); font-weight: 600; transition: border-color var(--dur-quick) var(--ease-out);
  --own-w: clamp(96px, 13vw, 148px); --touch-min-w: max(var(--own-w), var(--touch-floor)); min-width: var(--touch-min-w); /* §터치타깃 하한 주석 */
}
.chip.on { border-color: var(--text); box-shadow: var(--shadow-sig-sm); }
.chip:active { transform: translateY(1px); }
/* 재인쇄 프레임 칩 결제중 잠금(2026-07-15, app.js setReprintControlsDisabled) — .primary-btn:disabled/
   .pay-qty-btn:disabled와 동일 패턴. pointer-events:none으로 disabled 속성이 씹히는 예외 케이스까지 이중 차단. */
.chip:disabled { opacity: .4; cursor: not-allowed; pointer-events: none; }
/* 프리미엄 칩 미리보기(2026-07-20, 디자인 상향 — 포토이즘 벤치마킹) — 정사각 평면 스와치는
   가장자리 장식만 있는 프레임 오버레이 SVG를 작은 크기에서 "그냥 색칠된 사각형"처럼 보이게
   만드는 원인이었음(실측: 84개 프레임 중 75개가 실제 오버레이 아트를 갖고 있지만 정사각형
   축소판에선 잘 안 드러남). 해결: 1) 실제 인화 스트립과 같은 세로비(3:4)로 바꿔 상하 프레임
   장식이 더 잘 보이게, 2) ::after 글로시 대각선 하이라이트로 평면색만 있는 항목도
   "카드"처럼 입체감 있게, 3) 항상 은은한 그림자 + 호버 시 살짝 떠오르는 인터랙션 추가.
   JS는 inline style로 background(-color/-image)만 계속 쓰므로(§app.js renderFrameChips 등)
   이 규칙들과 절대 충돌하지 않음 — 크기/비율/그림자/글로시 레이어만 이 파일이 담당. */
.chip-prev {
  position: relative; width: clamp(90px, 12vw, 136px); aspect-ratio: 3 / 4; height: auto; overflow: hidden;
  border-radius: calc(var(--radius-5) * var(--ui-radius-scale)); background-size: cover; background-position: center;
  box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 10%, transparent), 0 6px 16px color-mix(in srgb, var(--text) 14%, transparent);
  transition: transform var(--dur-move) var(--ease-out), box-shadow var(--dur-move) var(--ease-out);
}
/* position:relative(2026-07-16, 방문 목적 추천) — 아래 .chip-rec-badge를 이 안에서 절대
   위치시키기 위한 기준점. 그 전엔 자식이 없던 빈 장식용 div라 이 추가가 다른 화면 영향 없음. */
.chip-color {
  position: relative; width: clamp(90px, 12vw, 136px); aspect-ratio: 3 / 4; height: auto; overflow: hidden;
  border-radius: calc(var(--radius-5) * var(--ui-radius-scale)); border: 1px solid var(--line);
  box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 10%, transparent), 0 6px 16px color-mix(in srgb, var(--text) 14%, transparent);
  transition: transform var(--dur-move) var(--ease-out), box-shadow var(--dur-move) var(--ease-out);
}
/* 글로시 하이라이트 — inline background(색/오버레이 이미지) 위에 항상 얹히는 대각선 광택
   레이어(실제 DOM이 아닌 생성 콘텐츠라 JS의 inline background와 절대 충돌하지 않음). */
.chip-color::after, .chip-prev::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(155deg, rgba(255,255,255,.32) 0%, rgba(255,255,255,0) 38%, rgba(0,0,0,.10) 100%);
}
.chip:hover .chip-color, .chip:hover .chip-prev { transform: translateY(-3px); box-shadow: 0 2px 4px color-mix(in srgb, var(--text) 12%, transparent), 0 10px 22px color-mix(in srgb, var(--text) 18%, transparent); }
/* 방문 목적 추천(2026-07-16 신설) — features.momentFrames opt-in. "추천" 배지는 접근성
   기존 .ctrl-count("N종" 배지)와 동일한 색 조합(밝은 카드 배경 + --pink-text, WCAG AA 확보
   — --pink 원색은 배경용일 뿐 텍스트에는 못 씀, §:root 주석 참고)을 재사용해 새 대비 계산을
   또 하지 않는다. 코랄 보더로 프레임 칩과 시각적으로 구분. */
.chip-rec-badge {
  position: absolute; top: -8px; right: -8px; z-index: 1;
  background: var(--card); border: 1.5px solid var(--pink); color: var(--pink-text);
  font-size: calc(10px * var(--ui-font-scale)); font-weight: 800; letter-spacing: .02em;
  padding: 2px 7px; border-radius: calc(var(--radius-pill) * var(--ui-radius-scale));
  box-shadow: var(--shadow-sig-sm); white-space: nowrap;
}
/* 시즌 프레임 종료임박 배지(2026-07-24) — .chip-rec-badge(우상단, 핑크)와 동시에 붙어도
   안 겹치게 반대쪽 모서리(좌상단)에, 색도 --text/--bg 반전 조합(테마 7종 전부에 존재하는
   핵심 변수만 사용 — 특정 테마에만 있는 색 하드코딩 안 함)으로 구분. */
.chip-ending-badge {
  position: absolute; top: -8px; left: -8px; z-index: 1;
  background: var(--text); color: var(--bg);
  font-size: calc(10px * var(--ui-font-scale)); font-weight: 800; letter-spacing: .02em;
  padding: 2px 7px; border-radius: calc(var(--radius-pill) * var(--ui-radius-scale));
  box-shadow: var(--shadow-sig-sm); white-space: nowrap;
}
/* 방문 목적 칩(2026-07-16) — 프레임/테마팩 칩과 동일한 .chip 카드톤을 그대로 쓰되, 이모지가
   커서 별도 폰트 크기 지정만 추가(색상 견본 .chip-color/.chip-prev는 여기선 안 씀). */
.chip-occasion-emoji { font-size: calc(28px * var(--ui-font-scale)); line-height: 1; }
/* [hidden] 명시 재확인(2026-07-16) — 이 섹션 자체엔 별도 display 규칙이 없어 사실 UA 기본
   [hidden]→none으로 충분하지만, 이 파일의 다른 조건부 섹션들(.modal-overlay[hidden] 등)과
   동일한 방어적 관례를 그대로 따름(§923줄 부근 주석 — 나중에 이 셀렉터에 display 규칙이
   추가돼도 안전하게 덮어쓰기 위함). */
#frame-occasion-section[hidden] { display: none; }
/* 인원수 기반 상품 추천(2026-07-26) — #frame-occasion-section과 동일한 방어적 [hidden] 재확인. */
#layout-partysize-section[hidden] { display: none; }
/* ── 선택 칩 카테고리 탭(2026-08-07, 큐 §B) ─────────────────────────────────
   테마팩/BASIC/EVENT를 한 번에 한 종류만 보여주기 위한 탭. 새 색을 만들지 않고 기존
   토큰(--card/--line/--text/--bg/--ink-soft)만 조합한다 — 테마 27종 전부에 존재하는
   변수라 매장 테마를 바꿔도 대비가 따라온다(§gate_theme.py가 재는 값들).
   활성 탭은 --text 면에 --bg 글자(기본테마 실측 16.97:1), 비활성은 --card 면에 --ink-soft.
   ★한 카테고리뿐이면 JS가 이 줄 자체를 hidden으로 둔다 — 탭 하나짜리 탭줄은 자리만 먹는다. */
.cat-tabs { display: flex; gap: var(--space-3); flex: 0 0 auto; }
.cat-tabs[hidden] { display: none; }   /* 위 .chip-row[hidden]과 같은 이유 */
.cat-tab {
  flex: 1 1 0; min-width: 0; min-height: var(--touch-floor);
  padding: var(--space-3) var(--space-4);
  background: var(--card); color: var(--ink-soft);
  border: 1.5px solid var(--line); border-radius: calc(var(--radius-5) * var(--ui-radius-scale));
  font-family: inherit; font-size: calc(15px * var(--ui-font-scale)); font-weight: 700;
  letter-spacing: .02em; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  transition: border-color var(--dur-quick) var(--ease-out), background var(--dur-quick) var(--ease-out);
}
.cat-tab:hover:not(.on) { border-color: var(--text); }
.cat-tab:active { transform: translateY(1px); }
.cat-tab.on { background: var(--text); color: var(--bg); border-color: var(--text); }
/* 탭 안의 개수 배지 — h3 옆 .ctrl-count와 같은 표현("N종")이지만 활성 탭에서 배경이 --text로
   뒤집히므로 색을 상속시킨다(고정색을 박으면 활성 탭에서 안 읽힌다). */
.cat-tab-count { font-family: var(--mono); font-size: calc(12px * var(--ui-font-scale)); font-weight: 700; opacity: .75; margin-left: 6px; }

/* ── 페이지네이션이 켜졌을 때만 칩 한 단계 축소(2026-08-07, 큐 §B) ───────────
   실측(1280x720, 선택열 976x467): 기본 칩은 견본 136px → 높이 224px이라 세로 예산에
   **한 행(6칸)**밖에 안 들어간다. 테마팩 101종이면 17페이지 = 스크롤 20화면과 별로 다르지
   않다. 견본을 줄이면 높이가 같이 줄어(측정표: 96px→171 / 88px→160 / 80px→150) 두 행이
   들어가고, `.chip`의 min-width(--own-w)가 열 수의 실제 상한이므로 그것도 함께 줄인다.
   ★`.paged`는 JS가 **넘칠 때만** 붙인다 — 프레임 몇 종뿐인 매장의 칩 크기는 그대로다.
   ★터치 하한은 그대로 지킨다(min-width는 max(--own-w, --touch-floor), 높이 150px ≫ 44px). */
.edit-controls.paged .chip { --own-w: clamp(84px, 8vw, 108px); }
.edit-controls.paged .chip-color,
.edit-controls.paged .chip-prev { width: clamp(64px, 6.2vw, 92px); }
/* ★폭을 min-width가 아니라 **width로 고정**한다 — min-width만 두면 이름이 긴 칩이 제 글자
   폭만큼 넓어져 그 행만 8칸이 아니라 7칸이 된다. 페이지 크기는 첫 행에서 잰 열 수로
   계산하므로, 그런 행이 섞인 페이지에서 마지막 행이 컨테이너 밖으로 밀린다(실측으로 걸림:
   테마팩 3페이지 16칸 중 2칸이 밖, 넘침 115px). 폭을 고정하면 이름이 두 줄로 접힐 뿐
   격자가 어긋나지 않고, 두 줄이 된 행의 높이는 JS가 **가장 큰 칩** 기준으로 잰다. */
.edit-controls.paged .chip { width: var(--touch-min-w); }
.edit-controls.paged .chip > span { text-align: center; overflow-wrap: anywhere; }
/* ★스티커 칩은 위 축소 규칙에서 **빼낸다**(2026-08-07, 큐 §B2). 프레임 칩과 크기 체계가
   전혀 달라(실측 1280x720: 스티커 58x57 대 프레임 158x224) `--own-w: clamp(84px,8vw,108px)`를
   그대로 먹이면 스티커가 **커진다** — 실측으로 확인했다: 한 행 14칸 → 8칸, 행수 22 → 38로
   오히려 나빠졌다. 축소 규칙은 "큰 칩을 줄인다"는 전제로 만들어진 것이라 작은 칩엔 반대로 는다.
   그래서 스티커는 제 크기 체계를 지키되 **폭 고정은 물려받는다** — 폭을 고정해야 글자 긴
   텍스트 스티커(자연폭 실측 최대 136px)가 그 행만 칸수를 줄여 마지막 행을 밀어내지 않는다
   (윗줄 width 주석의 그 사고). 터치 하한은 --touch-min-w = max(--own-w, --touch-floor)로 유지.
   ★폭은 디자인값 56px이 아니라 **72px**로 둔다 — 아래 줄바꿈 규칙과 한 쌍이다. 텍스트
   스티커를 52px(56 − 패딩) 안쪽에 접으면 네 줄이 되는데, 행 높이는 §paginateChipGroup이
   **가장 큰 칩** 기준으로 잡으므로 이모지 290여 개가 있는 행까지 같이 높아진다. */
.edit-controls.paged #sticker-list .chip { --own-w: 72px; }
/* ★그리고 글자 스티커는 `.paged`일 때 **접히게** 만든다. 폭만 고정하고 이걸 빠뜨리면 긴 문구가
   제 칩 밖으로 **옆 칩 위까지 삐져나온다** — 실촬영 PNG를 확대해서야 잡혔다. 측정으로는 안
   잡힌다: gate_overflow의 '잘린텍스트'는 스크롤 조상 기준이라 제 칩은 넘어도 컨테이너 안이면
   통과한다(그래서 §tools/probes/sticker_chip_overflow.js를 따로 만들어 15개·최대 18px로 셌다).
   ★기본(비 .paged) 모습은 종전 그대로 nowrap이다 — 그때는 칩이 글자만큼 넓어지므로 문제없다.
     이 nowrap은 원래 §buildStickerList가 **인라인으로** 박던 값이라 어떤 규칙으로도 못 이겼다.
     그래서 JS에서 `.chip-text` 클래스로 옮기고 여기서 두 경우를 나눈다.
   ★폭 72 + 접기 조합은 후보표(§tools/probes/sticker_width_table.js)에서 고른 것이다:
     56/nowrap=넘침22 · 72/nowrap=넘침15 · **72/접기=넘침0, 칩높이 57→67로 늘지만 행수는 3 그대로,
     쪽당 33 · 10페이지** · 96/접기=넘침0이지만 쪽당 27로 12페이지. 즉 넓히면 페이지만 늘고
     얻는 게 없다. */
#sticker-list .chip-text { white-space: nowrap; }
.edit-controls.paged #sticker-list .chip-text { white-space: normal; overflow-wrap: anywhere; }
/* ── 밀집 단계(2026-08-07, 큐 §B2-a) ─────────────────────────────────────────
   위 `.paged` 축소로도 **한 행밖에 안 들어갈 때만** JS가 `.dense`를 덧붙인다
   (§paginateChipGroup의 밀집 재시도 — 붙여보고 실제로 더 들어갈 때만 남긴다).
   ★아래 수치는 전부 **1280x720에서 렌더된 폭**이다(clamp의 최대값이 아니다 — 4.2vw가 54px,
     6.8vw가 87px로 걸린다). 실측 근거: 필터 108종, `python tools/probe.py --at result
     --js-file tools/probes/filter_width_table.js`.
   `.paged`만으로는 견본 79x106 → 칩 높이 **164px**인데 세로 예산이 **289px**이라 두 행(338px)이
   안 들어가 8개/쪽 **14페이지**가 된다. 견본이 54px면 칩 높이 **130px** → 두 행 + 한 행 10칸
   = 20개/쪽 **6페이지**.
   ★후보표에서 고른 값이다 — 더 줄인다고 좋아지지 않는다: 견본 49px는 칩 높이 123으로 더
     작아지지만 페이지수는 6으로 같고(열 수가 안 늘어) 견본만 알아보기 어려워진다. 반대로
     견본 64px는 칩 높이 143으로 두 행 문턱(139.5)을 못 넘어 14페이지 그대로다.
   ★칩 폭을 함께 줄이는 이유는 열 수(8→10)를 늘리기 위해서다. 다만 너무 줄이면 이름이 더
     접혀 칩이 **높아진다**(실측: 견본59+칩폭77 조합은 137→152로 늘었다). 그래서 JS가
     "붙여보고 더 들어갈 때만 채택"으로 판정한다 — 이 값들이 어느 매장에서 역효과여도 안전하다.
   ★스티커는 안 걸린다 — `#sticker-list .chip`의 --own-w(ID 특정도 1,3,0)가 아래 규칙(0,4,0)을
     이기고, 스티커 칩엔 .chip-prev/.chip-color 자체가 없다. 실측으로 확인(스티커 3행 유지). */
.edit-controls.paged.dense .chip-color,
.edit-controls.paged.dense .chip-prev { width: clamp(44px, 4.2vw, 62px); }
.edit-controls.paged.dense .chip { --own-w: clamp(72px, 6.8vw, 88px); }
/* ── 남는 세로 예산 흡수(2026-08-07, 가로 빈 띠) ─────────────────────────────
   `.chip-fill` + `--chip-row-h`는 §app.js paginateChipGroup이 **판정을 다 끝낸 뒤** 활성 목록
   상자에만 실어 보낸다. `--chip-row-h` = "rowsFit 행이 세로 예산을 정확히 나눠 가진 높이".
   왜 필요한가 — 페이지네이션은 행 단위로 자르므로 rowsFit 행을 채우고 남은 예산이 통째로
   버려지고, 그게 페이저 아래 빈 띠로 보였다. 실측 1280x800: 프레임 테마팩 98~272px
   (본문의 16%, 1040x117), 결과 필터 36~66px, 결과 스티커 85~239px(평균 149 = 선택열의 28%).
   ★`align-content:stretch`(CSS 기본값)에 맡기면 안 된다 — 실측으로 걸렸다: 검색으로 칩이
     하나만 남으면 그 한 행이 예산을 다 가져가 **칩 하나가 99x418px**이 됐다. 행이 rowsFit보다
     적은 페이지(마지막 페이지)도 같은 꼴이다. 그래서 높이를 값으로 못 박고, 남는 자리는
     `align-content:flex-start`로 아래에 그냥 둔다(목록이 거기서 끝났다는 뜻이라 읽힌다).
   ★넘칠 수 없다 — rowsFit 정의상 `rowsFit*칩높이 + 간격 <= 예산`이라 역산값은 언제나 원래
     칩 높이 이상이고, JS가 floor로 내려 반올림 1px 넘침도 막는다(§gate_overflow 0건 확인).
   ★`min-height`를 0으로 내리지 않는다 — 측정 순간(칩 전부 보임)엔 선택열이 넘치는 상태라
     0을 주면 목록이 내용보다 작게 눌려 칩이 잘린다. flex-grow만 준다(페이저를 바닥에 붙여
     선택열 아래끝이 왼쪽 미리보기 열과 같은 높이에서 끝나게 하는 역할).
   ★[hidden] 재확인 — 아래 `display:flex`가 UA의 `[hidden]{display:none}`을 이기므로,
     이 클래스가 붙은 채 감춰지는 경로가 생기면 감춘 섹션이 되살아난다(§.chip-row[hidden]
     ·§#layout-list .layout-card[hidden]에서 실제로 겪은 함정). 값이 싸니 미리 눌러둔다. */
.edit-controls.paged .chip-fill { flex-grow: 1; }
/* 목록이 섹션 안에 든 탭(테마팩·필터·스티커)은 섹션도 세로 flex여야 안쪽 목록이 따라 늘어난다.
   `.ctrl-section`은 이미 그렇고, `#frame-themepack-section`은 평범한 block이라 여기서 맞춘다. */
.edit-controls.paged .chip-fill:not(.chip-row) { display: flex; flex-direction: column; gap: var(--space-4); }
.edit-controls.paged .chip-fill:not(.chip-row) > .chip-row { flex-grow: 1; }
.edit-controls.paged .chip-fill[hidden] { display: none; }
.edit-controls.paged .chip-fill .chip-row,
.edit-controls.paged .chip-fill.chip-row { align-content: flex-start; }
.edit-controls.paged .chip-fill .chip { height: var(--chip-row-h, auto); }
/* 늘어난 칩 안에서 견본·이름이 위에 붙어 있으면 빈 띠가 **칩 안으로** 옮겨갈 뿐이다.
   (지금도 같은 행에 두 줄짜리 이름이 섞이면 한 줄짜리 칩 아래가 15px 비어 있다 — 같이 고쳐진다.) */
.edit-controls.paged .chip { justify-content: center; }
.tool-row { display:flex; flex-wrap:wrap; gap: var(--space-4); }
/* 아이콘+짧은텍스트 라벨(2026-07-14, 접근성) — 같은 화면의 .edit-btn(sel-flip 등)과 동일하게
   .ic/.lb 2단 구성으로 변경(터치에서 title 속성만으론 설명이 안 보임 대응). 기존 48x48
   정사각형 아이콘전용 레이아웃 → .edit-btn과 같은 세로 flex + min-width로 전환. */
.tool-btn {
  background: var(--card); border: 1.5px solid var(--line); border-radius: calc(var(--radius-5) * var(--ui-radius-scale));
  padding: var(--space-3) var(--space-4); display:flex; flex-direction:column; align-items:center; gap: 3px;
  --own-w: 56px; --touch-min-w: max(var(--own-w), var(--touch-floor)); min-width: var(--touch-min-w); /* §터치타깃 하한 주석 */
  cursor: pointer; color: var(--text); font-size: calc(11px * var(--ui-font-scale)); font-weight: 700;
  transition: border-color var(--dur-quick) var(--ease-out), transform var(--dur-move) var(--ease-spring);
}
.tool-btn .ic { font-size: calc(20px * var(--ui-font-scale)); line-height: 1; }
.tool-btn .ic svg { width: 20px; height: 20px; vertical-align: middle; }
.tool-btn:hover { border-color: var(--text); }
.tool-btn:active { transform: translateY(1px); }
/* 글자는 --pink-text, 테두리는 --pink (2026-08-09 감사) — 이 파일 :root 주석이 정한 규칙
   그대로다("--pink 원색은 AA 미달, 실제 읽는 텍스트에는 --pink-text"). 이 두 줄만 그 규칙
   밖에 있었다. 형제 규칙(.edit-btn.active·.qrpos-btn.active 등)은 전부 --pink-text를 쓴다.
   실측 대비: 기본 coral-paper 3.79:1, luami-green 4.03, wood-cream 3.16, pastel-pink 2.94 —
   11px 일반 텍스트는 4.5:1이 필요하다. 해당 문구는 결과화면의 "전체삭제"·"전체지우기"와
   선택된 도구 라벨이라, 안 보이면 손님이 되돌릴 방법을 못 찾는다.
   테두리·그림자는 장식이라 브랜드 컬러(--pink)를 그대로 유지한다(주석이 정한 경계). */
.tool-btn.on { border-color: var(--pink); color: var(--pink-text); box-shadow: var(--shadow-sig-sm); }
.tool-btn.danger { color: var(--pink-text); border-color: var(--pink); }
/* 프레임 디자인 BASIC/EVENT — EVENT 목록 없으면 헤딩·행 숨김 */
#frame-list-event:empty { display: none; }

/* 커스텀 텍스트 각인(2026-07-15) — 손님이 직접 문구를 타이핑하는 입력행. .chip/.tool-btn과
   동일한 카드톤(테두리·라운드·색상 변수)을 공유해 옆 sticker-list 칩 목록과 이질감이 없게. */
.sticker-custom-row { display:flex; gap: var(--space-3); align-items:stretch; margin: 2px 0; }
.sticker-custom-input {
  flex: 1 1 auto; min-width: 0; background: var(--card); color: var(--text); font-family: inherit;
  border: 1.5px solid var(--line); border-radius: calc(var(--radius-5) * var(--ui-radius-scale));
  padding: var(--space-3) var(--space-4); font-size: calc(14px * var(--ui-font-scale));
}
.sticker-custom-input:focus { outline: none; border-color: var(--text); }
.sticker-custom-row .tool-btn { flex: 0 0 auto; --own-w: 64px; --touch-min-w: max(var(--own-w), var(--touch-floor)); min-width: var(--touch-min-w); }

/* 스티커 오버레이(편집 미리보기 위) — 하루필름 photoResult 드래그/리사이즈/회전 그대로 참고 */
.sticker-layer { position: absolute; inset: 0; pointer-events: none; touch-action: none; }
.sticker-el {
  position: absolute; display:flex; align-items:center; justify-content:center;
  line-height: 1; cursor: grab; user-select: none; pointer-events: auto;
  transform: translate(-50%, -50%); filter: drop-shadow(0 2px 6px rgba(0,0,0,.25));
  touch-action: none;
  /* 터치타깃 하한 예외(2026-08-14, #78) — 스티커는 손님이 크기를 정하는 **그림**이지 버튼이
     아니다. 키보드 선택 진입 때문에 role="button"이 붙어 있어(§app.js renderStickerOverlay)
     위 §터치타깃 하한(.screen [role="button"] { min-width/height: 44px })이 그대로 걸리고,
     인라인 width/height 를 이겨 **화면 크기가 인화 크기와 달라졌다**.
     실측(헤드리스 1280x720, 스트립 600x1800, sizeFrac 0.14): 캔버스에 구워진 잉크는 84px
     = 화면 환산 22px 인데 DOM 사본은 44x44px — 정확히 2배. 그래서 기본 크기 스티커 셋이
     서로 다른 자리에 있어도 상자끼리는 계속 겹쳤다(§gate_overlap "겹침 sticker-el").
     조작용 터치타깃은 핸들이 이미 갖고 있다(.handle::before 26px, .del::before 56px).
     ★이 예외를 되돌리려면 §app.js wireStickerEl 의 .del 중재(보이는 원 밖이면 이동)도 같이
       봐야 한다 — 상자가 44px로 커지면 .del 56px 히트박스가 몸통 중앙을 덮는다. */
  --touch-min-w: 0px; --touch-min-h: 0px;
}
.sticker-el .sticker-emoji { pointer-events: none; display:block; }
.sticker-el.dragging, .sticker-el.resizing, .sticker-el.rotating { cursor: grabbing; z-index: 6; }
.sticker-el.selected { outline: 2px dashed var(--select-handle); outline-offset: 5px; z-index: 5; }
.sticker-el .handle {
  display: none; position: absolute; background: var(--select-handle); border: 2px solid #fff; border-radius: 50%;
  box-shadow: 0 1px 5px rgba(0,0,0,.35); pointer-events: auto;
}
.sticker-el.selected .handle { display: block; }
.sticker-el .handle.resize { width: 18px; height: 18px; right: -9px; bottom: -9px; cursor: nwse-resize; }
.sticker-el .handle.rotate { width: 16px; height: 16px; left: 50%; top: -26px; transform: translateX(-50%); cursor: grab; }
/* 터치타겟 확대(2026-07-15) — §12-62와 같은 WCAG 최소 터치타겟 기준으로 실측 지적된 결함.
   시각 크기(18px/16px)는 그대로 둬 캔버스 밀도·드래그 정밀도를 지키고, ::before로 투명
   히트박스만 24~28px까지 넓힌다. 생성콘텐츠(::before)는 실제 DOM 노드가 아니라 클릭 이벤트는
   항상 호스트 요소(.handle)로 귀속되므로 wireStickerEl()의 기존 pointerdown 리스너가 그대로 동작. */
.sticker-el .handle::before {
  content: ''; position: absolute; top: 50%; left: 50%;
  width: 26px; height: 26px; transform: translate(-50%, -50%);
}

/* 에디터에서 추가한 신규 텍스트·이미지 요소(2026-07-11, remote.js applyCustomElementsForScreen) —
   position:absolute % 좌표는 editmode.js의 기존 data-editable 요소와 동일 컨벤션. pointer-events:none
   고정 — 결제·촬영 버튼 위에 얹혀도 절대 클릭을 가리지 않음(겹침 경고 UI가 필요 없는 이유). */
.ce-el { position: absolute; box-sizing: border-box; pointer-events: none; }
.ce-text {
  display: flex; align-items: center; overflow: hidden; white-space: pre-wrap;
  color: var(--text); font-family: var(--font); line-height: 1.3;
}
.ce-img { overflow: hidden; }
.ce-img img { width: 100%; height: 100%; object-fit: contain; display: block; }
.sticker-el .del {
  position: absolute; top: -10px; right: -10px; width: 22px; height: 22px; border-radius: 50%;
  background: var(--pink); color: var(--on-pink); font-size: calc(13px * var(--ui-font-scale)); line-height: 22px; text-align: center; cursor: pointer;
}
/* 터치타겟 확대(2026-07-15) — 삭제 버튼은 WCAG 최소 터치타겟(44px) 미달(22px)이었음. 시각
   크기(22px 원)는 캔버스 밀도 유지를 위해 그대로 두고, ::before 투명 히트박스로 56px까지
   확대(위 .handle::before와 동일 원리 — 이벤트는 항상 .del로 귀속). */
.sticker-el .del::before {
  content: ''; position: absolute; top: 50%; left: 50%;
  width: 56px; height: 56px; transform: translate(-50%, -50%);
}

/* ============================================================
   8) 미리보기 — 인화물처럼
   ============================================================ */
/* gap 26 → 12px (2026-08-07) — 이 화면에 있는 것은 인화물 한 장과 QR위치 버튼 한 줄뿐이고,
   손님이 보는 것은 인화물이다. 격자 샘플링상 본문의 44%가 빈 사각형인데 그 대부분은 세로로
   긴 인화물 좌우라 구조적으로 못 채운다 — 채울 수 없다면 **인화물을 키워** 상대적으로 줄인다.
   인화물은 높이에 막혀 있으므로(폭은 비율이 따라온다) 세로 여유를 주는 것이 곧 확대다. */
.preview-stage { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap: 12px; min-height:0; }
/* 이 화면만 본문 패딩을 줄인다 — 다른 화면은 목록·칩이 가장자리에 붙으면 답답해지지만,
   여긴 가운데 한 장뿐이라 패딩이 그대로 인화물 크기를 깎는다. */
#screen-preview .lp-body { padding-top: 10px; padding-bottom: 10px; }
.final-preview { flex:1; display:flex; align-items:center; justify-content:center; min-height:0; }
.print-frame canvas {
  background:#fff; padding: 0; box-shadow: var(--shadow-float);
}
/* 필름 리빌 이펙트(feat/preview-film-reveal, 2026-07) — app.js가 #final-preview 캔버스에
   흑백·저대비→컬러·고대비 CSS filter 트랜지션을 인라인 스타일로 얹는다(§app.js primeFilmRevealFilter/
   playFilmRevealFilter). 접근성: prefers-reduced-motion 사용자는 트랜지션 없이 즉시 최종 상태
   (필터 없음)로 보여야 하므로 인라인 style보다 우선하도록 !important로 강제 — JS 쪽에서
   matchMedia 분기 없이도 항상 안전. */
@media (prefers-reduced-motion: reduce) {
  .final-preview canvas { transition: none !important; filter: none !important; }
}
/* 모션 최소화 어드민 토글(2026-07-16 신설, features.reduceMotion) — 위 OS/브라우저
   prefers-reduced-motion과 동일한 최종상태를 <body class="reduce-motion">에도 적용. 공유
   키오스크라 OS 설정은 손님 선호를 못 담으므로 매장이 직접 켜는 값(§app.js applyReduceMotion)
   이 별도로 필요 — 위 미디어쿼리 규칙은 그대로 두고 셀렉터만 나란히 추가(동일 !important 필요,
   인라인 style보다 우선해야 함은 위 주석과 동일 이유). */
body.reduce-motion .final-preview canvas { transition: none !important; filter: none !important; }

/* ── 모션 최소화 전면 적용(2026-08-06) ──────────────────────────────────────
   위 두 규칙은 필름 리빌 **하나만** 다뤘다. 나머지(어트랙트 루프·준비 문구 펄스·QR 반짝임
   같은 무한 반복 장식)는 OS에서 "동작 줄이기"를 켜도 그대로 돌고 있었다. 오버슈트가 있는
   --ease-spring을 새로 넣으면서 이 구멍이 더 커지므로 여기서 함께 닫는다.

   ★무엇을 끄고 무엇을 남기는지가 이 블록의 핵심이다.
   끄는 것 = **장식성 무한 반복**(보고 있지 않아도 계속 움직여 시선을 끄는 것).
   남기는 것 = **진행·상태를 전달하는 움직임** — 스피너(spin/procspin/pmspin), 녹화 표시
   (recblink), 촬영 카운트다운. 이것들까지 끄면 "처리 중"이 "멈춤"으로 보여서, 접근성을
   챙기려다 오히려 무슨 일이 벌어지는지 알 수 없게 만든다. 동작을 줄이는 것과 정보를
   없애는 것은 다르다. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --ease-spring: var(--ease-out);   /* 오버슈트 제거 — 되튐이 어지럼의 주범 */
    --dur-tap: 60ms; --dur-quick: 100ms; --dur-move: 120ms;
  }
  .idle-blink, .ready-heading, .fortune-spin.spin,
  .print-fun-tap-hint, .print-qr-skeleton-shimmer { animation: none !important; }
  .fortune-spin.spin { filter: none !important; }
  /* 셔터 플래시는 지우지 않고 눌러둔다 — 촬영이 끝난 순간을 알리는 유일한 시각 신호라
     없애면 언제 찍혔는지 알 수 없다. 전체화면 흰 섬광만 완화한다. */
  .flash.go { opacity: .35 !important; }
}
body.reduce-motion {
  --ease-spring: var(--ease-out);
  --dur-tap: 60ms; --dur-quick: 100ms; --dur-move: 120ms;
}
body.reduce-motion .idle-blink,
body.reduce-motion .ready-heading,
body.reduce-motion .fortune-spin.spin,
body.reduce-motion .print-fun-tap-hint,
body.reduce-motion .print-qr-skeleton-shimmer { animation: none !important; }
body.reduce-motion .fortune-spin.spin { filter: none !important; }
body.reduce-motion .flash.go { opacity: .35 !important; }
.qrpos-row { display:flex; align-items:center; gap: var(--space-3); }
.qrpos-row .ctrl-label { margin: 0 4px 0 0; }
.qrpos-btn {
  background: var(--card); border: 1.5px solid var(--line); border-radius: calc(var(--radius-5) * var(--ui-radius-scale));
  min-width: 40px; height: 40px; padding: 0 var(--space-5); font-size: calc(13px * var(--ui-font-scale)); font-weight: 700;
  cursor: pointer; color: var(--ink-soft);
}
/* .qrpos-btn 라벨도 실제 한글 단어(하단 등)라 텍스트 기준(4.5:1) 적용 — 위 .edit-btn.active와 동일 이유. */
.qrpos-btn.active { border-color: var(--pink); color: var(--pink-text); box-shadow: var(--shadow-sig-sm); }

/* ============================================================
   7~8) 인쇄 진행 + QR (하루필름 PhotoPrint 3분할 — 인쇄중 QR 조기노출)
   ============================================================ */
/* min-height:0 (2026-08-06, 실측으로 발견) — flex 자식의 기본 min-height는 auto라 `flex:1`을
   줘도 **내용보다 작아지지 못한다**. 그래서 1280x720 키오스크에서 이 무대가 904px로 부풀어
   화면(720px)을 넘겼고, 그 아래 '처음으로' 버튼이 top≈994로 **화면 밖에 있었다** — 손님이
   인쇄가 끝난 뒤 대기화면으로 되돌아갈 수단이 없어 자동복귀 타이머만 기다려야 했다.
   같은 원인으로 여섯컷 상품이 잘려 도달 불가였던 적이 있다(§.grid overflow-y). 키오스크는
   스크롤이 없으므로 "넘친다 = 없다"이다. */
.print-stage {
  flex: 1; min-height: 0; display: flex; align-items: stretch; justify-content: center;
  /* 상하 40 → 26px(2026-08-07). 이 화면엔 상단바가 없어 위아래 패딩이 그대로 죽은 여백이
     되는데, 그만큼 결과물이 작아진다(결과물은 폭이 아니라 **높이**에 막혀 있다 — 실측). */
  /* 26px 4vw -> 12px 2vw (2026-08-08, 동주님 지시 "인쇄 기다리며 움짤 진행되는 화면 더
     가운데에 크게"). 위 주석대로 결과물은 **높이**에 막혀 있으므로 상하 패딩을 줄이는
     것이 곧 결과물 확대다. 좌우도 줄여 좁은 열에서 폭에 막히는 경우를 함께 푼다. */
  gap: 0; padding: 12px 2vw;
}
.print-col { display: flex; flex-direction: column; justify-content: center; min-height: 0; min-width: 0; }
/* 25% -> 17%, padding-right 32 -> 20px (2026-08-08, 동주님 지시 — 움짤을 더 크게).
   이 열은 안내문구라 폭이 줄어도 읽히지만, 옆의 결과물은 그만큼 커진다.
   매장별로 --print-side-pct 로 계속 덮어쓸 수 있다(기본값만 바꿨다). */
.print-col-message { flex: 0 0 var(--print-side-pct, 17%); padding-right: 20px; text-align: left; }
.print-col-preview { flex: 1 1 auto; align-items: center; text-align: center; min-height: 0;}
.print-col-qr { flex: 0 0 var(--print-side-pct, 25%); align-items: center; padding-left: 32px; gap: var(--space-7); }

.print-msg {
  font-size: var(--fs-h3); font-weight: 800; line-height: 1.5;
  letter-spacing: -.01em; margin: 0 0 14px;
}
.print-msg .qr-accent { color: var(--pink-text); }
.print-retention { color: var(--ink-soft); font-size: calc(14px * var(--ui-font-scale)); line-height: 1.6; margin: 0; }

/* 인쇄대기 인터랙티브 순간(2026-07-23, §index.html print-fun-tap 주석 참고) — 순수
   장식용 탭 영역. position:relative로 자식 반짝이(.print-fun-sparkle)의 기준점 역할만
   하고, 그 외엔 레이아웃에 전혀 관여하지 않는다(높이는 힌트 텍스트 줄 하나 분량뿐). */
.print-fun-tap {
  position: relative; margin-top: 18px; padding: 14px 4px; cursor: pointer;
  border-radius: calc(var(--radius-6) * var(--ui-radius-scale));
}
.print-fun-tap-hint {
  color: var(--ink-soft); font-size: calc(13px * var(--ui-font-scale)); font-weight: 600;
  opacity: .7; animation: printFunTapPulse 2.2s ease-in-out infinite;
}
@keyframes printFunTapPulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
body.reduce-motion .print-fun-tap-hint { animation: none; opacity: .75; }
/* JS(app.js spawnPrintFunSparkle)가 탭 위치에 동적으로 생성·자동제거하는 반짝이 파티클 —
   순수 CSS 애니메이션(위치·이모지만 JS가 지정), 인쇄 로직과 전혀 무관. */
.print-fun-sparkle {
  position: absolute; pointer-events: none; font-size: 22px; z-index: 2;
  animation: printFunSparklePop .7s ease-out forwards;
}
@keyframes printFunSparklePop {
  0% { transform: translate(-50%, -50%) scale(.4); opacity: 1; }
  60% { transform: translate(-50%, -80%) scale(1.15); opacity: 1; }
  100% { transform: translate(-50%, -130%) scale(.9); opacity: 0; }
}
body.reduce-motion .print-fun-sparkle { display: none; }

/* 인쇄 미리보기 무대(2026-08-07 전면 재구성, 동주님: "움짤 미리보기도 가운데 크게 띄워줘야지")
   ────────────────────────────────────────────────────────────────────────────────
   종전엔 정지 인화물만 이 박스 안에 있고 **움짤(GIF)과 무빙그리드는 박스의 형제**였다.
   셋이 같은 열에서 세로로 쌓이며 높이를 다툰 결과, 실촬영하면 박스가 460x690이어야 할 것이
   **460x225로 눌린 채 셋이 서로 겹쳐** 뒤엉켜 있었다. 움짤은 박스 밖으로 삐져나오고
   무빙그리드가 그 위를 또 덮었다 — 손님이 자기 사진을 알아볼 수 없는 상태였다.

   지금은 **하나의 무대**다. 넷(정지 인화물·영상·움짤·무빙그리드)이 같은 자리에 절대배치로
   겹쳐 있고 §app.js showPrintLayer()가 한 번에 하나만 켠다. 형제가 사라졌으니 박스는 열
   높이를 그대로 쓴다 — max-width 460px 상한도 없앴다. 이 화면의 주인공은 결과물이고,
   손님이 15~20초 동안 하는 일은 그걸 보는 것뿐이다.

   크기: `height:100%` + `aspect-ratio` + `width:auto`. 폭을 먼저 정하고 높이를 따라가게
   하면(구 `width:100%`) 열이 넓을 때 높이가 남고 좁을 때 넘친다 — 키오스크는 화면 높이가
   고정이므로 **높이를 먼저 채우고 폭이 따라오는** 쪽이 맞다. `max-width:100%`가 좁은 열에서
   가로 넘침을 막는다. */
.print-preview-box {
  position: relative;                       /* 안쪽 레이어 4장의 절대배치 기준 */
  /* 비율은 **상품의 실제 인화 캔버스**를 따른다(2026-08-07 2차 수정).
     4/6 하드코딩이었는데 세로 스트립 상품의 캔버스는 600x1800(1:3)이다. 그래서 무빙그리드
     미리보기가 인화물을 **가로로 정확히 2.00배 늘려** 보여줬고(실측 3해상도 전부), 정지
     인화물은 contain이라 안 늘어난 대신 상자의 약 49%가 빈 채 남았다. 즉 같은 하드코딩이
     레이어마다 다른 증상으로 나타나고 있었다.
     §app.js가 인쇄화면에서 session.layout.canvas로 이 변수를 채운다. 값이 없으면 종전
     동작(4/6)으로 떨어져 기존 매장과 동일하다. */
  height: 100%; width: auto; max-width: 100%; aspect-ratio: var(--print-aspect, 4 / 6); margin-inline: auto;
  border-radius: calc(var(--radius-4) * var(--ui-radius-scale)); background: var(--card); border: 1px solid var(--line);
  display: flex; align-items: center; justify-content: center; overflow: hidden;
  box-shadow: var(--shadow-float);
}
/* 레이어 4장 — 같은 자리에 겹친다. object-fit:contain이라 무대 비율과 다른 결과물(정사각
   움짤, 가로 스트립 등)도 잘리지 않고 안에 온전히 들어간다. 종전 video는 cover였는데,
   cover는 비율이 다르면 **가장자리를 잘라낸다** — 손님 얼굴이 잘릴 수 있어 contain으로 통일. */
.print-preview-box > #print-preview-video,
.print-preview-box > #print-gif-img,
.print-preview-box > #print-moving-grid,
.print-preview-box > .print-final-still {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain;
  margin: 0; border: 0; border-radius: inherit; background: transparent;
}
.print-placeholder {
  display: flex; flex-direction: column; align-items: center; gap: var(--space-6);
  color: var(--ink-soft); font-family: var(--mono); font-size: calc(13px * var(--ui-font-scale)); letter-spacing: .12em;
}
/* 무빙그리드 프리뷰(2026-07-13) — #print-preview-box를 꽉 채우는 실제 인쇄 그리드 재현.
   좌표는 layoutRects()가 준 %를 그대로 절대배치(app.js showMovingGridPreview 참고). */
.print-moving-grid { position: relative; width: 100%; height: 100%; overflow: hidden; background: var(--card); }
.print-moving-grid .mg-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
.mg-cell { position: absolute; overflow: hidden; border-radius: 0; background: #000; }   /* 사진칸 = 직각 */
.mg-cell-img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* 컷 진행 카운터(2026-07-16 신설) — showMovingGridPreview()가 매 사이클 새로 붙이는
   "N/M" 배지. 각 셀 GIF가 실제로 로드(또는 실패)될 때마다 N이 올라간다(가짜 타이머 아님).
   기존 무빙그리드 시각 효과(셀 배치·애니메이션)는 그대로, 오버레이만 추가. */
/* 진짜 굴절 유리 재질(2026-07-23, §beautify-toggle 상단 주석과 동일 근거/원칙) — 이 배지
   바로 밑에서 무빙그리드 GIF 셀들이 움직이므로 saturate()의 "렌즈 통과" 효과가 가장 잘
   보이는 지점 중 하나. 배경 불투명도(.55)는 WCAG 대비 유지를 위해 그대로 유지. */
.mg-progress-counter {
  position: absolute; top: 10px; right: 10px; z-index: 5;
  background: rgba(0,0,0,.55); color: #fff;
  backdrop-filter: blur(10px) saturate(1.7);
  -webkit-backdrop-filter: blur(10px) saturate(1.7);
  border: 1px solid rgba(255,255,255,.14);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 2px 8px rgba(0,0,0,.22);
  font-family: var(--mono); font-size: calc(12px * var(--ui-font-scale)); font-weight: 700;
  letter-spacing: .03em; padding: 4px 10px; border-radius: 999px; pointer-events: none;
}

.print-progress-wrap {
  width: 100%; max-width: 460px; height: 6px; margin-top: 20px;
  background: var(--line); border-radius: calc(var(--radius-3) * var(--ui-radius-scale)); overflow: hidden;
}
.print-progress-bar {
  height: 100%; width: 100%; background: var(--pink);
  transform-origin: left; animation-name: print-countdown; animation-timing-function: linear; animation-fill-mode: forwards;
}
@keyframes print-countdown { from { transform: scaleX(1); } to { transform: scaleX(0); } }

/* QR 크기·여백(2026-08-08) — **실제로 디코딩해서** 정한 값이다(§tools/gate_qr_scan.py).
   ────────────────────────────────────────────────────────────────────────────
   동주님: "QR 결제 실제로 카메라에 갖다 대면 인식되는지 확인해야하고".
   지금까지는 "QR이 화면에 보이는가"까지만 봤는데, 보이는 것과 읽히는 것은 다르다.
   화면을 캡처해 OpenCV로 디코딩해보니 **6개 조건 중 4개만 통과**했다:
     · 원본/축소60%/축소40%/기울기12° → 읽힘
     · **축소40%+흔들림 → 검출만 되고 디코딩 실패**
     · **축소30%(멀리) → 검출만 되고 디코딩 실패**
   즉 화면에 폰을 바짝 대면 읽히지만 **팔 뻗은 거리에서 손이 흔들리면 실패**한다.

   원인은 실측에 그대로 나왔다 — `안쪽여백 0px`. QR 규격은 코드 사방에 **4모듈 이상의
   흰 여백(quiet zone)**을 요구하는데, 180px QR이 200px 박스에 padding 0으로 들어가 있어
   코드 바로 옆이 10px 주황 테두리다. 디코더가 코드 경계를 못 찾는다("검출만" = finder
   패턴은 봤는데 격자를 못 읽었다는 신호).

   그래서 두 가지를 고친다:
     1) padding으로 흰 여백 확보 — 전형적 QR(25~33모듈, 여기선 약 6px/모듈) 기준 4모듈 ≈ 26px.
        여유를 둬 28px.
     2) 박스를 키운다(200 → 268px). 모듈이 커질수록 멀리서·흔들려도 읽힌다.
        세로형 1920 화면에서 268px는 높이의 14%로, QR 열 폭(256px 대) 안에 들어간다.
   ※테두리 주황은 유지한다 — 손님 시선을 QR로 끄는 역할이고, 여백만 확보하면 인식과 무관하다. */
.print-qr-box {
  /* 356px = QR 코드 280 + quiet zone 28x2 + 테두리 10x2.
     1차로 268px를 줬더니 padding이 안쪽을 깎아 **코드 자체는 180→192px로 거의 안 커졌고**
     디코딩 결과도 4/6 그대로였다(실측). 인식률을 정하는 것은 박스가 아니라 **모듈 크기**다.
     280px 코드는 약 29모듈 기준 모듈당 9.6px — 40%로 축소해도 3.9px가 남아 흔들림에 견딘다
     (192px일 때는 축소 후 2.6px라 1px 블러에 뭉개졌다).
     세로형 실측에서 QR 박스가 x406에 놓여 좌우로 406px씩 남았다 — 356px는 충분히 들어간다.

     ★상한을 뷰포트에 묶는다(2026-08-14). 356px 고정은 1920x1080·1080x1920에서는 맞지만
     **1280x720**(윈도우 배율 150%)에서는 화면 높이의 49%가 된다. 실측:
       1280x720 완료화면 — 결과물(손님 인화물 미리보기) 184x555=101,805px²
                           vs QR박스 356x356=126,736px²  → QR이 1.24배 더 크다.
     손님이 받아갈 물건보다 안내용 QR이 더 큰 화면은 위계가 뒤집힌 것이고,
     gate_print_layout P1("결과물이 인쇄화면 최대 요소")이 정확히 그걸 잡는다.
     그래서 min()으로 **작은 화면에서만** 줄인다 — 1080p 이상에서는 356px 그대로라
     지금까지 실측해 온 디코딩 여유(모듈 9.6px)가 안 바뀐다.
     26vh 근거: 720px 화면에서 187px → 코드 자체는 187-2*(10+28)=111px, 약 29모듈 기준
     모듈 3.8px로 여전히 스캔 가능하다. 그리고 그 화면의 결과물(555px 높이)보다 확실히 작다. */
  width: min(356px, 26vh); height: min(356px, 26vh); border: 10px solid var(--pink); border-radius: calc(var(--radius-6) * var(--ui-radius-scale));
  background: #fff; padding: 28px; box-sizing: border-box;
  display: flex; align-items: center; justify-content: center;
  animation: printqr-pop .5s ease;
}
.print-qr-box img { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
@keyframes printqr-pop { 0%{transform:scale(0)} 70%{transform:scale(1.08)} 100%{transform:scale(1)} }
.print-qr-hint { font-size: calc(14px * var(--ui-font-scale)); font-weight: 600; color: var(--ink-soft); text-align: center; line-height: 1.5; }

/* QR 대기 스켈레톤(2026-07-16 신설) — 업로드 완료 전 #print-qr-box가 hidden인 동안 완전히
   빈 칸 대신 노출하는 CSS-only 시머 placeholder(§app.js revealPrintQr/hidePrintQr/
   resetPrintScreenUI가 hidden 토글). 크기는 실제 QR박스와 동일해 교체 시 레이아웃이
   튀지 않는다. */
.print-qr-skeleton {
  /* ★위 .print-qr-box와 **같은 식**이어야 한다 — 다르면 QR이 뜨는 순간 레이아웃이 튄다.
     2026-08-14에 박스를 min(356px, 26vh)로 바꾸면서 여기도 같이 바꿨다(2026-08-08 주석이
     요구한 동반 수정 규칙 그대로). 숫자를 고칠 때는 반드시 두 곳을 함께 고칠 것. */
  width: min(356px, 26vh); height: min(356px, 26vh); border-radius: calc(var(--radius-6) * var(--ui-radius-scale));
  background: var(--card); border: 1px solid var(--line);
  position: relative; overflow: hidden;
}
.print-qr-skeleton[hidden] { display: none; }
.print-qr-skeleton-shimmer {
  position: absolute; inset: 0;
  background: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--text) 14%, transparent) 50%, transparent 70%);
  transform: translateX(-100%);
  animation: print-qr-shimmer 1.6s ease-in-out infinite;
}
@keyframes print-qr-shimmer { to { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
  .print-qr-skeleton-shimmer { animation: none; }
}
/* 모션 최소화 어드민 토글(2026-07-16, features.reduceMotion) — 위 prefers-reduced-motion과
   동일 처리(무한반복 시머 정지)를 <body class="reduce-motion">에도(§app.js applyReduceMotion). */
body.reduce-motion .print-qr-skeleton-shimmer { animation: none; }

.print-note {
  text-align: center; color: var(--pink-text); font-weight: 700; font-size: calc(14px * var(--ui-font-scale)); margin-top: 12px;
}
/* 분실물 확인 문구(2026-08-06) — 인쇄 대기 시간에 손님이 실제로 하는 일(짐 챙기기)에 맞춘
   안내. 유리 반사·서서 보는 거리(50~70cm)를 감안해 부제 크기 이상으로 두고, 인화물을 집으러
   몸을 숙이기 직전에 걸리도록 '처음으로' 버튼 바로 위 전체 폭에 놓는다. */
.print-leavebehind {
  align-self: center; text-align: center; margin: 0 auto .8vh;
  font-size: clamp(calc(15px * var(--ui-font-scale)), calc(2.1vh * var(--ui-font-scale)), calc(24px * var(--ui-font-scale)));
  font-weight: 700; letter-spacing: -.01em; color: var(--text); opacity: .78;
}
.print-home-btn { align-self: center; margin: 0 auto 3vh; }

/* 셔터 애니메이션(인쇄 placeholder에서 재사용) */
.shutter { width: 92px; height: 92px; border-radius: 50%; border: 3px solid var(--ink-soft);
  position: relative; }
.shutter span, .shutter::before, .shutter::after {
  content:""; position:absolute; inset: 14px; border-radius:50%; border: 3px solid var(--pink);
  border-right-color: transparent; border-bottom-color: transparent; animation: spin 1s linear infinite;
}
.shutter::before { inset: 24px; opacity: .6; animation-duration: 1.4s; }
@keyframes spin { to { transform: rotate(360deg); } }

/* 잠금(점검)·카메라/프린터 보호 오버레이 — 매장 테마 무관 고정 화이트/블랙, 장식 최소화
   (2026-07-09: 예전엔 var(--bg)/var(--text)로 매장 테마를 그대로 물려받았는데, "이건 매장 화면이
   아니라 시스템 안내"라는 걸 테마와 무관하게 항상 같은 모습으로 구분되게 하는 게 더 직관적이라
   고정 팔레트로 전환). */
.lock-overlay { position:fixed; inset:0; background: var(--sys-bg); z-index:999; display:flex; align-items:center; justify-content:center; }
/* 접근성(2026-07-14) — 동적 생성되는 hw-protect(§app.js updateHwProtect)가 공용 openModal/
   closeModal 헬퍼(hidden 속성 통일 토글)로 갈아탐에 따라 필요. .modal-overlay[hidden]과
   동일한 이유(같은 클래스에 display:flex 선언이 있어 UA기본 [hidden]→none을 덮어씀). */
.lock-overlay[hidden] { display: none; }
/* 코드 입력 모달(2026-08-08, 동주님: "이용권 코드 들어갔는데 화면 바뀌고 키패드 눌러도
   입력안되고 정리 안되어 있는거 해결해") ─────────────────────────────────────────
   이용권·사전선택 코드 입력 UI가 위 잠금 오버레이의 마크업을 **스타일까지 통째로**
   재사용하고 있었다. 그런데 위 팔레트는 일부러 매장 테마를 무시하는 시스템 고정
   흰/검이다. 결과(다크 테마 매장에서 헤드리스 실렌더로 확인):
     · 배경이 흰색 — 손님이 쓰던 어두운 화면에서 갑자기 흰 화면으로 튄다("화면 바뀌고")
     · .num-char는 background:var(--bg)(어두움)인데 글자색은 --sys-fg(검정)을 물려받아
       **입력한 글자가 검은 칸에 검은 글씨로 안 보인다** — 손님은 "입력이 안 된다"고 느낀다.
       실제로는 입력되고 있었다(하네스로 3키 눌러 "123"이 DOM에 들어간 것 확인).
     · 취소·사용하기 버튼은 어두운 배경 전제로 만든 색이라 흰 바탕에서 거의 안 보인다
   코드 입력은 **점검 안내가 아니라 손님이 쓰는 매장 화면**이다. 매장 테마로 되돌린다.
   잠금·보호 오버레이는 그대로 시스템 팔레트를 유지한다(이 클래스가 붙은 곳만 바뀐다). */
.lock-overlay.code-entry { background: var(--bg); }
.lock-overlay.code-entry .lock-box { color: var(--text); }
.lock-box { text-align:center; color: var(--sys-fg); max-width: 80vw; }
.lock-ico { font-size: calc(56px * var(--ui-font-scale)); margin-bottom:18px; opacity:.85; }
.lock-msg { font-size: clamp(calc(20px * var(--ui-font-scale)), calc(3.2vw * var(--ui-font-scale)), calc(32px * var(--ui-font-scale))); font-weight: 700; letter-spacing:-.01em; line-height:1.5; }

/* 원격 메시지 */
.remote-msg {
  position:fixed; top:30px; left:50%; transform:translateX(-50%) translateY(-30px);
  background: var(--sys-fg); color: var(--sys-bg); padding:var(--space-7) 32px; border-radius:calc(var(--radius-7) * var(--ui-radius-scale));
  font-size: calc(22px * var(--ui-font-scale)); font-weight:700; opacity:0; transition:.3s; z-index:1000; max-width:80vw; text-align:center;
}
.remote-msg.show { opacity:1; transform:translateX(-50%) translateY(0); }

/* 네트워크 끊김 배너(2026-07-10) — remote-msg와 달리 자동으로 안 사라짐(연결 복구까지 유지),
   화면을 가리지 않는 얇은 하단 바 — lock-overlay처럼 전체를 막으면 촬영·꾸미기 등 로컬로도
   되는 동작까지 방해하게 됨. 시스템 안내 고정 팔레트(--sys-*) 재사용. */
.offline-banner {
  position:fixed; left:0; right:0; bottom:0; z-index:998;
  background: var(--sys-fg); color: var(--sys-bg); padding:var(--space-4) var(--space-8); text-align:center;
  font-size: calc(15px * var(--ui-font-scale)); font-weight:700; transform: translateY(100%); transition: transform .3s;
  pointer-events: none;
}
.offline-banner.show { transform: translateY(0); }

/* 세로형 대응 */
@media (max-aspect-ratio: 1/1) {
  /* padding 없음 — 위 select-stage(§.select-stage 모바일 규칙)와 동일하게 box-container의
     패딩에 맡김(2026-07-16, 다른 진행화면과 동일 크롬 적용). */
  .cap-stage.cap-stage--3col { flex-direction: column; gap: var(--space-5); }
  /* ★2026-08-07 (X1 검증) — 여기 있던 `.cap-status-panel`·`.cap-preview-panel` 규칙은
     **가리키는 요소가 없는 죽은 선택자**였다. 촬영화면 2열 재설계(919f204)가 그 두 패널을
     없애고 `.cap-side` 레일 하나로 합쳤는데 이 세로형 블록만 옛 이름 그대로 남았다
     (실측: `grep -c cap-status-panel public/index.html` = **0**, public/js·admin도 0).
     그 결과 1080x1920 세로 매장에서 `.cap-side`는 가로형 규칙(`flex:0 0 33%`/`max-width:33%`)을
     그대로 뒤집어썼고 — 세로 배치에선 33%가 **높이**로 가는 반면 max-width는 **폭**을 33%로
     묶어서 — 레일이 253px짜리 상자로 화면 **왼쪽 아래 구석**에 쭈그러들었다. 썸네일은
     70x28px, 화면 하단 절반은 통째로 빈 채였다(실촬영 PNG로 눈으로 확인).
     세로형에서는 카메라 아래에 **폭을 꽉 채운 레일**이 남은 높이를 전부 가져간다. */
  .cam-holder { flex: 0 0 auto; max-width: 94vw; width: 94vw; max-height: 46vh; order: 1; }
  /* 레일 높이 상한 35vh — 남은 높이를 전부 주면(flex:1) 레일이 **카메라보다 커져서**
     X1의 ①(카메라가 화면 최대 요소)이 깨진다. 실측: 상한 없이 두면 레일 1015x893 =
     907,022px² > 카메라 1015x728 = 738,977px². 이 화면은 거울이지 정보판이 아니므로
     정보 쪽에 상한을 건다. 35vh(1920 기준 672px) < 카메라 728px. */
  .cap-side {
    order: 2; width: 94vw; max-width: 94vw; flex: 1 1 auto; min-height: 0;
    max-height: 35vh; align-self: center;
  }
  /* 세로형에선 남은 높이가 넉넉하므로 썸네일이 그 높이를 전부 가져간다 — 가로형과 같은
     그리드(3열 · grid-auto-rows:1fr · 스크롤 없음) 그대로라 규칙을 새로 만들지 않는다. */
  .preview-photo { width: 110px; flex-shrink:0; }

  .edit-stage { flex-direction: column; }
  .select-stage { flex-direction: column; }
  .pay-split { grid-template-columns: 1fr; }

  .print-stage { flex-direction: column; align-items: center; padding: var(--space-8) var(--space-7); }
  .print-col-message, .print-col-preview, .print-col-qr { flex: none; width: 100%; }
  .print-col-message { text-align: center; padding-right: 0; margin-bottom: 20px; }
  .print-col-qr { padding-left: 0; margin-top: 20px; }
}

/* ============================================================
   키보드 포커스 링(2026-08-03, 라운드67) — 비텍스트 대비 WCAG 1.4.11

   ★비대칭이었다. 어드민 콘솔(admin.css 맨 끝)은 라운드31에서 이 링을 얻었는데,
   **public/ 쪽에는 `:focus-visible`이 저장소 전체에 한 줄도 없었다**(전수확인).
   ※이 파일을 쓰는 페이지는 **index.html(키오스크 본체) 하나뿐**이다 — 손님 폰 페이지
   (upload·preselect·report·download·gallery·wait·diag)는 각자 인라인 스타일이라 이
   블록이 닿지 않는다. 다만 그 7개는 `outline:none`이 **0건**이라(전수확인) 브라우저
   기본 링이 살아 있다. 여기서 고칠 것은 기본 링을 **지운** 아래 규칙이다
   (2026-08-14 #79로 .chip-search가 사라져 한 개만 남았다):
     .sticker-custom-input:focus { outline: none; ... } (스티커 직접입력칸)
   그 자리를 대신하던 `border-color: var(--text)`는 :hover와 값이 같아 "올려놓은 것"과
   "포커스가 온 것"이 구분되지 않았다 — 라운드31이 어드민 .name-input에서 잡은 것과 같은 모양.

   링 색은 --pink. 키오스크의 표면 **전부**에서 3:1을 넘는 것을 실측해 골랐다:
     --bg #f1ebdd 3.41 · --card #fbf7ee 3.79 · --film #14110c 4.65 · 흰 배경 4.05 · --text 4.52
   (--text는 어두운 촬영화면 --film 위에서 1.03이라 링으로 쓸 수 없다 — 그래서 --pink다.)

   ★`:focus`가 아니라 `:focus-visible`을 쓴다 — 브라우저가 "키보드로 옮겨온 포커스"라고
   판단할 때만 링이 붙는다. 실측한 것은 여기까지다: **새로 연 페이지에서 스크립트로
   focus()만 주면 링이 안 생기고(outline-style:none), 실제 Tab 키를 누르면 생긴다**
   (rgb(223,75,46) 3px). ★**실제 마우스 클릭·터치는 검증하지 못했다** — 브라우저 페인
   뷰포트가 0x0이라 진짜 클릭을 못 넣는다(라운드31~66과 같은 한계). 브라우저에 따라
   텍스트 입력칸은 클릭으로도 링을 그릴 수 있으므로, 손님 화면에서 링이 보이면 그건
   버그가 아니라 이 규칙이다. 이 블록을 파일 맨 끝에 두는 이유는 위 두 `outline:none`과
   특이도가 같아 **나중 선언이 이기게** 하기 위함이다.
   ============================================================ */
:where(a[href], button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 3px solid var(--pink);
  outline-offset: 2px;
}
/* 위 `outline:none`은 (0,2,0)이라 :where() 규칙(0,1,0)을 이긴다 — 같은 특이도로 되받는다 */
.sticker-custom-input:focus-visible {
  outline: 3px solid var(--pink);
  outline-offset: 2px;
}

/* ── [hidden] 복원(2026-08-07, 전수조사로 발견) ────────────────────────────────
   작성자 스타일의 `display:flex|grid|block`은 UA 스타일시트의 `[hidden]{display:none}`을
   **항상 이긴다.** 그래서 마크업에 hidden을 달아 놓고도 화면에는 그대로 보이는 요소가
   8개 있었다 — 매장이 끄기로 한 기능의 **빈 UI가 계속 떠 있었다**:
     · 피부보정 컨트롤(features.beautify 꺼진 매장에서도 노출)
     · 인쇄화면 QR 박스·움짤 이미지(준비 전 빈 자리)
     · 대기화면 시즌배너·단계 미리보기(opt-in인데 항상 노출)
     · 촬영화면 포즈가이드 라벨(내용 없이 "포즈 가이드" 글자만) — 실촬영에서 발견
   전역 `[hidden]{display:none !important}`는 쓰지 않는다 — .lp-timer[hidden]처럼
   "자리는 유지하고 안 보이게"가 의도인 예외가 이미 있고, !important는 그걸 깨뜨린다.
   해당 셀렉터에만 명시적으로 되돌린다(§tools/gates.py 게이트 G가 재발을 막는다). */
.idle-season-banner[hidden],
.idle-step-preview[hidden],
.beautify-toggle[hidden],
.preview-title[hidden],
.pose-guide-section[hidden],
.qrpos-row[hidden],
.print-gif-img[hidden],
.print-qr-box[hidden] { display: none; }

/* ── 인쇄화면 재설계(2026-08-07, 동주님 지시 — 소비자 심리 순서) ──────────────
   인쇄 대기 15~20초 동안 손님은 ①"잘 나왔나?"(압도적 1순위) ②언제 나오지 ③폰에 어떻게 받지
   ④짐 챙기기 순으로 생각한다. 그런데 재인쇄 업셀이 `.modal-overlay`로 **화면 전체를 덮어
   방금 찍은 사진을 못 보게** 만들고 있었다(실촬영 12-print.png). 만족을 확인하기 전에 지갑을
   요구하는 순서라 **전환율도 오히려 떨어진다** — 사람은 만족한 뒤에 산다.
   덮개가 아니라 **하단에서 올라오는 시트**로 바꾼다. 결과물은 계속 보이고, 제안은 시야
   아래쪽에서 기다린다. 모달 자체의 동작(openModal/closeModal·포커스트랩·타이머)은 그대로다. */
#reprint-modal {
  align-items: flex-end; justify-content: center;
  background: color-mix(in srgb, #000 32%, transparent);   /* 결과물이 비쳐 보이게 딤을 낮춘다 */
}
#reprint-modal .modal-box {
  width: min(100%, 980px); max-width: none;
  max-height: 46vh !important;                              /* 화면 위 절반은 결과물의 몫 */
  border-radius: calc(var(--radius-8) * var(--ui-radius-scale)) calc(var(--radius-8) * var(--ui-radius-scale)) 0 0;
  margin: 0; animation: sheetUp .32s var(--ease-out) both;
}
@keyframes sheetUp { from { transform: translateY(14%); opacity: .2; } to { transform: none; opacity: 1; } }
/* 시트가 올라오면 인쇄 콘텐츠를 위로 압축한다(2026-08-07) — 하단 시트로 내리는 것만으로는
   부족했다(실측: 결과물 bottom 542 vs 시트 top 389 → 153px 겹침). 인쇄화면이 세로를 거의
   다 쓰고 있어서, 시트가 뜨면 필연적으로 결과물을 가린다. 시트가 열린 동안에만 무대를
   위쪽 절반으로 접어 **결과물과 제안이 서로 다른 자리에** 있게 한다.
   :has()로 모달 상태를 읽으므로 JS 변경이 없다 — 열고 닫는 로직은 손대지 않는다. */
body:has(#reprint-modal:not([hidden])) .print-stage { max-height: 46vh; padding-top: 2vh; padding-bottom: 0; }
body:has(#reprint-modal:not([hidden])) .print-col-message,
body:has(#reprint-modal:not([hidden])) .print-leavebehind,
body:has(#reprint-modal:not([hidden])) .print-home-btn { display: none; }
/* 남는 폭은 전부 결과물에 — 이 순간 손님이 확인하고 싶은 건 '내 사진'뿐이다.
   QR은 시트를 닫으면 다시 나타난다(시트는 25초 상한이 있다). */
body:has(#reprint-modal:not([hidden])) .print-col-qr { display: none; }
@media (prefers-reduced-motion: reduce) { #reprint-modal .modal-box { animation: none; } }
/* ★위 46vh 상한이 정작 눌러야 할 두 버튼을 화면 밖으로 밀어냈다(2026-08-07, 같은 날 발견).
   실측 1280x720: `.modal-box` clientHeight 331 / scrollHeight 371 이고
   `괜찮아요`·`카드로 결제하기` 둘 다 top 644~bottom 724 vs 컨테이너 한계 389~720 —
   **하나도 안 보인다**. 손님은 사겠다고도 됐다고도 못 하고 25초 타이머가 닫아주기를
   기다려야 했다. 업셀 시트인데 사는 길과 거절하는 길이 동시에 막혀 있었다.
   (인라인 max-height:92vh 였을 땐 안 넘쳤다 — 시트 재설계가 46vh로 조이면서 생긴 회귀다.)

   시트 높이(결과물이 위 절반을 갖는다는 결정)는 **그대로 두고 행동 버튼만 바닥에 고정**한다.
   프레임교체·재조합 opt-in을 켠 매장은 내용이 훨씬 길어지는데, 그때도 버튼은 늘 같은 자리에
   있다 — 고정 높이를 다시 계산해 맞추는 방식(어느 한 조합에서만 맞는다)을 피한 이유다.
   좌우 5vw 패딩 밖까지 덮어야 글이 버튼 옆으로 비쳐 지나가지 않으므로 음수 마진으로 넓히고,
   그 때문에 생길 수 있는 가로 스크롤은 overflow-x로 막는다(세로는 인라인 auto 그대로). */
#reprint-modal .modal-box { overflow-x: hidden; }
#reprint-modal .pay-modal-actions {
  position: sticky; bottom: 0; z-index: 1;
  margin: 16px -5vw 0; padding: 14px 5vw 18px;
  background: var(--card);
  box-shadow: 0 -10px 18px -10px rgba(0,0,0,.22);
}
/* 버튼을 고정한 것만으로는 부족했다 — 그러면 이번엔 **제안 문구가** 위로 밀려 안 보인다.
   실측 내역(1280x720, 331 안에 395): 위패딩36 + 제목32+16 + (본문 위16)+24+16 + 수량44+16
   + 금액19+16 + (행동 위16)+108 + 아래패딩36. 여기서 손님에게 아무것도 주지 않는 세 곳만 뗀다:
     · 아래패딩 36 — 시트 바닥은 이제 고정된 행동 바가 자기 패딩으로 맡는다(실측: 바가
       바닥에서 36px 떠 있었다). 0으로.
     · 위패딩 36 → 18 — 가운데 뜨는 대화상자가 아니라 바닥에서 올라오는 시트다.
     · 제목 아래 16 과 본문 위 16 이 겹쳐 32px 간격이 돼 있던 것 → 16.
   합 325 ≤ 331. 매장 문구를 자르거나 글자를 줄이지 않았다 — 여백만 뗐다. */
#reprint-modal .modal-box { padding-top: 2.5vh; padding-bottom: 0; }
#reprint-modal .time-ext-body { margin-top: 0; }

/* 인쇄중 안내는 '읽는 글'이 아니라 '상태'다 — 큰 문장 4줄이 좌측을 먹고 그중 "QR"만
   강조색이라 위계도 어긋나 있었다. 폭을 줄이고 글자를 낮춰 결과물에 자리를 넘긴다.
   ④(짐 챙기기)는 인쇄가 끝나갈 때 필요한 정보이므로 분실물 문구는 반대로 키운다. */
/* ★폭 20%는 **문구가 들어가는지 재보지 않고** 정한 값이었다(2026-08-07 X2 1차, 아래 수치는
   gate_print_layout.py 실측). ※이 줄 깨짐은 X2가 만든 게 아니라 **원래 있던 것을 절반만
   줄인 것**이다 — X2 이전 판(81211a0^)에서도 가로형은 폭 224px·글꼴 큰 채로 **5줄**이었고,
   X2 1차가 4줄로 줄였을 뿐 3줄에 못 미쳤다(세로형은 반대로 X2 1차가 **깨뜨렸다** — 아래
   세로형 항목 참조. 같은 판에서 세로형은 3줄로 멀쩡했다). 가로 1280x720에서 이 칸의 실제 글자 폭은 **173px**인데 가장 긴
   줄 `사진이 인화되는 동안`은 17.3px 글꼴로 **약 177px**이라 한 줄을 못 버틴다 — 마크업이
   <br>로 지정한 3줄이 **4줄로**, 세로 1080x1920에서는 (아래 세로형 항목 참조) **5줄로**
   깨졌다. 24%로 넓힌다: 이 칸은 `flex:0 0`이라 남는 폭은 전부 중앙 열로 가는데, **중앙
   결과물은 `.print-preview-box{max-width:460px}`에 이미 막혀 있어 그 폭을 못 쓴다**
   (실측 가로형: 중앙 열 620px 중 상자는 458px만 사용 = 162px 놀고 있었다). 즉 여기서 45px를
   가져와도 **결과물은 1px도 안 줄어든다** — 실측으로 확인했다(수정 전후 결과물 rect 동일).
   `word-break:keep-all`은 한국어에서 어절 한가운데를 끊지 않게 하는 표준 처리다(§996 선례).
   폭 계산이 또 어긋나더라도 `동안`이 `동/안`으로, `다운로드`가 `다운로/드`로 잘리는 일은
   이걸로 막는다 — 폭은 문구를 바꾸면 다시 틀어지지만 이 규칙은 안 틀어진다. */
.print-col-message { flex: 0 0 24%; max-width: 24%; }
.print-msg { font-size: clamp(calc(15px * var(--ui-font-scale)), calc(2.4vh * var(--ui-font-scale)), calc(24px * var(--ui-font-scale))); line-height: 1.45; word-break: keep-all; }
.print-retention { font-size: calc(12px * var(--ui-font-scale)); word-break: keep-all; }
/* 결과물이 이 화면의 주인공 — 좌측에서 넘겨받은 폭을 중앙이 가져간다. */
.print-col-preview { flex: 1 1 auto; min-height: 0; }
.print-leavebehind { font-size: clamp(calc(16px * var(--ui-font-scale)), calc(2.5vh * var(--ui-font-scale)), calc(27px * var(--ui-font-scale))); opacity: .9; }
/* 인쇄 완료 시점에만 키운다(2026-08-07, X2 §할일 ④) — §app.js startPrintReturnCountdown이
   `body.print-done`을 붙이고 resetPrintScreenUI가 뗀다. 인쇄 **중**엔 손님의 1순위가
   "잘 나왔나"이므로 이 문구가 결과물과 크기를 다투면 안 되고, 인화물을 집어 드는 순간엔
   반대로 이게 1순위가 된다. 값은 위 clamp에 1.3배 — 세 마디(하한·선호·상한)를 같은 비율로
   올려 작은 화면에서 갑자기 커지지 않게 한다. transition은 크기 변화를 '알아채게' 하되
   모션 최소화 매장에선 끈다(§body.reduce-motion 관례). */
.print-leavebehind { transition: font-size .35s var(--ease-out), opacity .35s var(--ease-out); }
/* 좌측 문구 칸으로 옮기면서(2026-08-07) 위 안내문과 붙지 않게 간격을 준다. 종전엔 화면 폭
   전체를 쓰는 별도 띠라 가운데정렬이었지만, 이제 같은 칸의 다른 문구들과 왼쪽 정렬로 맞춘다. */
.print-col-message .print-leavebehind { margin: 22px 0 0; text-align: left; }
body.print-done .print-leavebehind {
  font-size: clamp(calc(21px * var(--ui-font-scale)), calc(3.25vh * var(--ui-font-scale)), calc(35px * var(--ui-font-scale)));
  opacity: 1;
}
body.reduce-motion .print-leavebehind { transition: none; }

/* ★세로형 되돌리기(2026-08-07 X2 검증) — 바로 위 두 값(`flex:0 0 24%`·`max-width:24%`)은
   **가로 3열 배치를 전제로** 쓴 것인데 미디어쿼리 없이 두는 바람에 세로형 매장까지 따라갔다.
   세로형 블록(§2065 `max-aspect-ratio:1/1`)은 `.print-stage`를 **세로 적층**으로 바꾸고
   세 열에 `flex:none; width:100%`를 준다 — 그런데 그 블록이 **위에** 있어서(같은 명시도면
   나중 규칙이 이긴다) 두 값이 그걸 이겼다. 결과(실측 1080x1920, gate_print_layout.py):
     · `max-width` — 문구가 폭 **184px** 칸에 갇혀 3줄이 **5줄로** 깨지고 `동안`이 `동/안`,
       `다운로드`가 `다운로/드`로 **단어 한가운데서** 잘렸다(PNG로 눈으로 확인).
     · `flex-basis` — 세로 적층에선 24%가 **폭이 아니라 높이**로 간다(§X1에서 `.cap-side`가
       똑같이 당했다. 가로형 값이 세로형에서 축을 갈아타는 이 함정은 이 파일의 재발 유형이다).
   그래서 세로형에서는 §2065가 의도한 값으로 명시적으로 되돌린다. 이 블록은 위 두 줄보다
   **아래**에 있어야 이긴다 — §2065 안으로 옮기면 다시 진다(그게 이 결함의 원인이었다).
   ※`max-aspect-ratio`/`min-aspect-ratio`로 갈라 쓰지 않은 이유: 정확히 1:1인 화면에서 두
   질의가 **동시에** 참이라 가로형 규칙이 나중에 와서 이긴다. §2065가 1:1을 세로형(적층)으로
   치므로, 세로형 규칙이 마지막에 오는 지금 배치가 그 판정과 어긋나지 않는다. */
@media (max-aspect-ratio: 1/1) {
  .print-col-message { flex: none; width: 100%; max-width: none; }
}

/* 인쇄 대기 중 결과물(2026-08-07) — §app.js showPrintFinalStill.
   무빙그리드/GIF/영상이 준비되면 그 위를 덮으므로 z-index를 낮게 둔다. */
.print-final-still {
  /* width/height를 100%로 채우고 object-fit:contain으로 비율을 지킨다.
     `max-height:100%` + `height:auto`는 **부모에 명시적 높이가 없으면 해석되지 않아**
     원본 크기 그대로 그려진다 — 2x6 세로 스트립이 화면 밖으로 삐져나갔다(실촬영으로 확인). */
  /* position은 static으로 둔다 — `.print-preview-box`에 position 지정이 없어(static)
     absolute를 쓰면 기준이 더 위 조상(.screen)으로 올라가 **화면 전체 크기가 되고**
     박스의 overflow:hidden에도 안 잘린다(실촬영에서 인화물이 화면 밖으로 삐져나갔다).
     박스가 이미 flex+center이므로 static + 100%/contain이면 정확히 안에 맞는다.
     video 형제(§.print-preview-box video)가 쓰는 방식과 동일하게 맞춘 것. */
  /* ★2026-08-07: 위 `.print-preview-box > *` 규칙이 넷을 같은 자리에 절대배치로 통일하면서
     이 규칙의 static 전제는 끝났다. 값은 그 규칙과 동일하게 두고(서로 싸우지 않게) 이력만
     남긴다 — 당시 absolute가 위험했던 이유는 박스가 position:static이라 기준이 .screen으로
     올라갔기 때문이고, 지금은 박스에 position:relative가 있어 그 함정 자체가 없다. */
  width: 100%; height: 100%; object-fit: contain;
  border-radius: calc(var(--radius-4) * var(--ui-radius-scale));
}
.print-final-still[hidden] { display: none; }
/* 무대 안에서는 무빙그리드도 한 장의 레이어 — 자체 배경/크기 규칙이 위 통일 규칙을 이기지
   않도록 여기서 맞춰준다(셀 절대배치는 그대로 동작한다). */
.print-preview-box > .print-moving-grid { background: var(--card); }

/* JS로만 켜고 끄는 요소의 [hidden] 복원(2026-08-07, 실촬영으로 발견) —
   `#print-placeholder`는 마크업에 hidden 속성이 없고 app.js가 `.hidden = true`로만 끄는데,
   `.print-placeholder{display:flex}`가 그걸 이겨 **인화물 옆에 "PRINTING IMAGE" 스피너가
   그대로 남았다.** 게이트 G는 마크업의 hidden 속성을 기준으로 찾으므로 이 부류를 못 잡았다
   — 그래서 게이트 G를 JS 토글 대상까지 보도록 함께 넓혔다. */
.print-placeholder[hidden] { display: none; }

/* 플로팅 도움 버튼의 아이콘/글자(2026-08-15) — 아이콘만으로는 무슨 버튼인지 안 읽힌다.
   글자는 화면 폭이 좁아지면 숨기고 아이콘만 남긴다(터치 타깃 46px은 그대로 유지). */
.lp-callstaff-btn .cs-ico, .voice-guide-btn .cs-ico { font-size: 20px; line-height: 1; }
.lp-callstaff-btn .cs-lb, .voice-guide-btn .cs-lb {
  font-size: 14px; font-weight: 700; letter-spacing: -.01em; white-space: nowrap;
}
@media (max-width: 620px) {
  .lp-callstaff-btn, .voice-guide-btn { width: 46px; padding: 0; border-radius: 50%; }
  .lp-callstaff-btn .cs-lb, .voice-guide-btn .cs-lb { display: none; }
  /* 좁은 화면 오프셋(구 right: calc(4vw + 56px))은 유틸 바가 대신한다 — 삭제. */
}
