불편·오류 신고 (CsReport)
팝업이 떠 있어도 신고에 닿을 수 있는지, 그 화면이 캡처에 담기는지를 확인하고 규칙을 찾아보세요.
Alpine 컴포넌트 + DS CSS · Web / Mobile · 진행 중(WIP)
Interactive playground
- 70확인·알림 다이얼로그--ds-z-modal-alert없음
- 68신고 모달--ds-z-modal-report없음
- 65딤 위 신고 버튼--ds-z-report-fab떠 있음
- 65어시스턴트 도크·FAB--ds-z-fab-ai없음
- 60모달 위 모달--ds-z-modal-top떠 있음
- 50모달 + 딤--ds-z-modal떠 있음
- 44채팅 FAB·패널--ds-z-fab떠 있음
- 40페이지 헤더 (헤더 안 신고 버튼)--ds-z-page-header떠 있음
captureFilter- 본문 화면
- 열려 있는 모달과 그 딤
- 어시스턴트 도크 · 채팅 패널 · 시트 레일 — 지금은 없음
- 신고 UI 자신 (data-cs-report-host)
- 확인·알림 다이얼로그 (.ds-modal-backdrop--top)
- display:none 서브트리 · 교차 출처 이미지
/* 보임/숨김을 CSS 가 정한다 — MutationObserver 를 두지 않는다(admin 2만 노드) */
body:has(.ds-modal-backdrop:not([style*="display: none"])) .ds-report-fab {
display: flex;
}
.ds-report-fab {
z-index: var(--ds-z-report-fab); /* 65 — 겹쳐 뜬 모달(60) 위 */
bottom: 104px; /* FAB 줄보다 한 칸 위 — AI FAB 과 좌표가 겹친다 */
right: calc(24px + var(--ds-ai-dock-w, 0px) + var(--ds-fab-dodge, 0px));
}
.ds-modal-backdrop--report {
z-index: var(--ds-z-modal-report); /* 68 — 어떤 팝업 위에서도 열린다 */
}
/* 지금 이 장면: 딤 위 버튼 보임 · ESC → 모달 위 모달 (60) */배경에 뭐가 떠 있는지를 바꿔 가며 딤 위 신고 버튼(65)과 신고 모달(68)이 어디에 서는지, 그리고 그 화면을 캡처하면 무엇이 담기고 무엇이 빠지는지를 볼 수 있습니다. 모달 자체는 평범한 일반 모달이라 여기서 다시 그리지 않고, 두 번 회귀한 두 축(닿을 수 있나 · 담기나)만 세웠습니다.
규칙 (강제) · 코딩 에이전트용
불편·오류 신고의 강제 규칙. 상세·근거는 아래 본문. FAB 세트의 가로 자리·비킴 계약은 채팅·AI FAB 문서가 정본입니다.
층 — 이 컴포넌트의 규격 그 자체다
- 신고 모달은
--report백드롭(z 68)이다 — 기본(50)을 쓰지 말 것. 신고는 화면이 이상할 때 부르는 것이라 그 화면이 이미 모달이거나 모달 위 모달(60)인 경우가 흔하다. 기본이면 그 아래로 들어가 「눌렀는데 아무 일도 안 난다」가 된다(2026-09-04 사용자 리포트). - 딤 위 신고 버튼
.ds-report-fab은 z 65 — 어시스턴트와 같은 층이다. 55 로 두면 겹쳐 뜬 모달(60)(admin 「근무 설정」 패널 위 「휴가/연차 유형」 같은 흔한 조합)의 딤에 그대로 깔린다. - 컨펌 다이얼로그(70)·권한 팝업(75)보다 위로 올리지 말 것. 결정을 묻는 팝업을 가리면 안 되고, 신고 모달 안에서 띄우는 확인 팝업도 이 순서라야 보인다. 오류 다이얼로그에는 「리포트 보내기」가 따로 있어 그 장면의 진입점은 그쪽이다.
- ⚠️ 헤더 안 버튼만 따로 올리는 방법은 없다. 헤더 래퍼의
z-index(그리고position: sticky)가 쌓임 맥락을 만들어, 그 안의 요소는 아무리 높은 z 를 줘도 바깥과 겨루지 못한다 — 자식에position: fixed를 줘도 같다. 한 번 이 길로 갔다가 되돌렸다. 딤 위 버튼이 별도 요소인 이유가 이것이다.
딤 위 버튼 — 상시 FAB 이 아니다
- 뜨는 조건은 「팝업이 떠 있을 때」뿐이고, 판정은 CSS 가 한다 —
body:has(.ds-modal-backdrop:not([style*="display: none"])). MutationObserver 를 쓰지 말 것: admin 은 2만 노드라x-show토글마다 도는 관찰 비용이 버튼 하나 값어치를 넘는다. - ⚠️ FAB 줄이 아니라 한 칸 위(
bottom: 104px)에 선다. 가로 계약이 AI FAB 과 같아 같은 줄에 두면 좌표가 정확히 겹친다(실측 둘 다x=1532). 둘 다 65 라 겹치면 그리는 순서가 승부를 가르는데, 그건 include 순서에 따라 화면마다 달라진다 — 눈에는 안 보이고 클릭만 엉뚱한 데로 가는 고장이다. - 면은 흰색 + 어두운 글리프다. 채팅(단색 다크)·AI(그라디언트)와 셋이 세로로 늘어서므로 면으로 구분된다. 테두리(
--color-line-normal)는 장식이 아니다 — 흰 원이 딤 위에 뜨면 경계가 뭉개진다. - ⚠️
<button>이 아니라<div role="button">이다. 전역 blanket 규칙이 모든<button>의border-radius를 4px 로 강제해서, 버튼으로 두면 원이 아니라 모난 사각으로 렌더된다(실측). 채팅·AI FAB 도 같은 이유로 div 다 → radius. 대신 키보드 조작(role/tabindex/Enter·Space)은 직접 달아야 한다. data-ds-tip="top"을 명시한다. 툴팁 싱글톤은.ds-btn--icon·.icon-btn만 자동으로 잡는데 이 버튼은 둘 다 아니라, 빠뜨리면 브라우저 기본 툴팁이 뜬다. 라벨은title이 지고data-ds-tip은 위치만 고른다 → 툴팁- 신고 모달이 열려 있는 동안에는 숨긴다 — 그때 딤은 이 모달의 것이고, 신고 중에 신고 버튼이 또 떠 있으면 무엇을 누르는지가 흐려진다.
캡처 — 늘리면 조용히 비어 간다
- ⚠️ 화면에 보이는 것은 웬만하면 빼지 말 것. 예전에는 클래스만 보고
.ds-modal-backdrop(모든 팝업)과.ds-fab-layer(채팅·어시스턴트 패널, 시트 레일)를 통째로 걸렀는데, 그러면 사용자가 신고하려는 그 화면이 그림에서 사라진다 — 팝업이나 어시스턴트가 문제일 때가 신고가 가장 필요한 순간인데 정작 증거가 빠진다(2026-09-04 사용자 리포트 2건, 하루에 두 번). - 지금 빼는 것은 셋뿐이다 — 신고 UI 자신(
data-cs-report-host— 모달과 딤 위 버튼이 둘 다 이 안에 있다), 확인·알림 다이얼로그(.ds-modal-backdrop--top), 그리고 iframe 의 안쪽 문서(아래). 여기에 항목을 더하려면cs-report-capture.test.ts에 회귀를 함께 넣을 것. - 임베드(iframe)는 자리만 남기고 회색으로 칠한다 — 안쪽을 끌어오지 말 것. html-to-image 는 같은 출처 iframe 이면 안쪽
<body>를 두 번 복제하고 그 위에 iframe 의 박스 스타일을 덮어씌운다(admin 은 dev·prod 모두 같은 출처 임베드). 임베드된 시트(수만 노드)까지 끌어오면 10초 안에 못 끝나고, 임베드 앱의position: fixed딤이 부모 화면 전체를 덮는다.captureFilter가 다른 문서의 노드를 거르고,replaceEmbedFramesInClone이 클론 안에서iframe, body body를 회색 상자 + 안내 한 줄로 바꾼다 — 캔버스에 나중에 덧칠하지 않는 이유는 임베드 위에 뜬 모달이 회색 상자 위에 그려져야 하기 때문이다. 회색으로 남긴 경로는 컨텍스트embedFrames로 전송되고, 미리보기 아래 노티스가 사용자에게 알린다(요구사항 §5.1). - 스크롤 위치는 스냅 마커로 되살린다 — 자식을
transform으로 밀지 말 것. html-to-image 는 스크롤을 옮기지 않아 시트를 오른쪽·아래로 넘겨 보던 화면이 맨 왼쪽·맨 위로 찍혔다(2026-09-14 재현, 온보딩 9/9 「누락」).markScrolledElements가 라이브 DOM 에data-cs-scroll="left,top"을 붙이고,restoreScrollInClone이 클론 컨테이너에scroll-snap-type: both mandatory+ 그 오프셋 자리의 1pxscroll-snap-align: start마커를 심는다 — SVG 이미지 안에서도 브라우저가 첫 레이아웃에서 그 자리로 스냅한다(hidden·auto·scroll 실측). 진짜 스크롤이라 sticky 헤더·고정 열이 화면과 같은 자리에 선다. 자식들을transform으로 밀어 올리면 sticky 가 같이 밀려 헤더가 사라진다. - 화이트리스트에는 Tailwind 4 의 개별 프로퍼티(
translate·rotate·scale)가 들어 있어야 한다.translate-*/rotate-*/scale-*유틸리티는transform이 아니라 이 프로퍼티를 쓴다 — 빠지면-translate-x-1/2로 가운데 맞춘 팝오버·드롭다운·토글 손잡이가 엉뚱한 자리에 그려져 옆 요소를 가린다.line-clamp-N의-webkit-line-clamp·-webkit-box-orient도 같다(display 만 복사되면 줄 제한이 풀려 아래 행을 밀어낸다). - 도크 폭만큼 크롭하지 말 것 — 도크가 열려 있으면 밀려난 본문과 도크가 함께 찍히는 것이 맞다.
- 성능 가드 두 개는 지운 게 아니라 필수다 — ①
display:none서브트리 통째 건너뛰기(admin 은 숨김 마크업이 2만+ 노드라 안 자르면 캡처가 수십 초, 실측 27,768 노드), ②includeStyleProperties화이트리스트(기본값은 모든 computed style 복사라 Tailwind 4 커스텀 프로퍼티 수백 개 × 요소 수로 메인 스레드가 30초 이상 멈췄다). toCanvas를 쓰지 말 것 —toSvg까지만 쓰고 나머지는 직접 한다. 중간 SVG 의 Alpine 속성(:class·@click)은 XML 로 유효하지 않은 이름이라 이미지 로드가 조용히(onerror 이벤트만) 실패한다.sanitizeCaptureXml()이 그 속성과--를 품은 주석을 지우는데,toCanvas에는 끼어들 훅이 없다.- 교차 출처 이미지는 캡처에서 뺀다(자리는 남는다). S3 presigned 아바타처럼 화면에는 잘 보이는 이미지가 CORS 없이 인라인에 실패하면 캡처 전체가 reject 된다(2026-09-03 실측, 로컬에서는 재현되지 않음).
- 캡처 상한은 긴 변 1920px · 제한 시간 10초. 3초로 두면 admin(dev)의 SVG 래스터화만 ~6.3초가 걸려 정상 캡처가 전부 실패 폴백으로 빠진다.
진입점과 개인정보
- 진입점은 넷이고 도착점은 하나다 — 헤더 버튼 · 딤 위 버튼 ·
Shift+F1· 오류 다이얼로그의 「리포트 보내기」. 전부hereby:cs-report-open이벤트를 쏜다. 다섯 번째 입구를 만들지 말 것(어시스턴트 안에 있던 진입점은 걷어냈다). - 헤더 버튼은 호스트가 있을 때만 보인다(
PageHeader.reportAvailable→[data-cs-report-host]조회). include 를 빠뜨린 앱에서 눌러도 아무 일 없는 죽은 버튼을 막는다. - 오류 다이얼로그는 뜨기 «직전» 에 예비 캡처를 쏜다(
hereby:cs-report-precapture, 유효 60초). 사용자가 신고하려는 것은 다이얼로그가 아니라 그 뒤 화면이라, 다이얼로그가 덮기 전 그림이 필요하다. [data-cs-mask]는 캡처 직전 검은 블록으로 자동 마스킹된다. 민감한 값을 새로 그리면 이 속성을 붙일 것 — 캡처는 사람이 열어 보기 전에는 무엇이 담겼는지 아무도 모른다.- 사용자 드래그 가림은 이미지에 굽는다(burn-in) — 원본은 전송되지 않는다. 되돌릴 수 없으므로 안내 문구(「가린 부분은 복구되지 않아요」)를 지우지 말 것.
- 접수 완료는 토스트가 아니라 모달 안에 남긴다 — 접수번호를 전화로 부를 수 있어야 한다. 복사 버튼이 실패해도 번호는 화면에 계속 보인다.
사용자가 스크린샷·최근 로그·환경 정보를 자동으로 붙여 불편이나 오류를 접수하는 모달입니다. 신고는 화면이 이상할 때 부르는 것이라, 이 컴포넌트의 어려움은 폼이 아니라 그 순간에 닿을 수 있는가와 그 화면이 증거로 남는가 둘입니다.
네 개의 입구, 하나의 도착점
| 입구 | 언제 | 어디에 |
|---|---|---|
| 페이지 헤더 「불편·기능 신고」 | 평상시 | PageHeader — 호스트가 있는 화면에만 |
딤 위 버튼 .ds-report-fab | 팝업이 떠 있을 때 | 화면 우하단, FAB 줄보다 한 칸 위 |
Shift+F1 | 언제나 | 전역 단축키(입력 중에도 안전) |
| 오류 다이얼로그의 「리포트 보내기」 | 오류가 났을 때 | ds-dialog |
넷 다 hereby:cs-report-open 이벤트를 쏘고, 같은 모달이 열립니다. 앱 코드에서도 같은 이벤트로 열 수 있어요.
왜 딤 위에 버튼이 하나 더 있나
팝업이 뜨면 헤더의 신고 버튼이 딤에 가려 못 누릅니다. 그런데 정작 팝업이 문제일 때가 신고가 가장 필요한 순간이에요. 그래서 그때만 뜨는 대체 진입점을 둡니다.
헤더 안의 버튼만 위로 올리는 방법은 없습니다 — 헤더 래퍼가 쌓임 맥락을 만들어 그 안의 z 는 바깥과 겨루지 못하고, 자식에 position: fixed 를 줘도 마찬가지예요. 위 Playground 에서 「모달 위 모달」을 골라 보면 65 가 왜 필요한 값인지 보입니다.
무엇이 함께 전송되나
폼 안에 「함께 전송되는 정보」 를 펼쳐 볼 수 있게 두었습니다(투명성). 앱·화면 주소·앱 버전·브라우저·최근 로그 건수·오류 정보가 실제 값으로 보이고, 로그는 스위치로 끌 수 있어요.
- 자동 마스킹 —
[data-cs-mask]가 붙은 요소는 캡처 직전 검은 블록으로 덮입니다. - 수동 가림 — 미리보기 위를 드래그하면 그 자리가 이미지에 구워집니다. 되돌릴 수 없어요.
- 첨부 — 파일 최대 3개, 각 10MB.
접수 후
접수번호는 토스트가 아니라 모달 안에 남습니다 — 전화로 부를 수 있어야 하니까요. 「처리 결과를 앱 알림으로 받을게요」를 켜 두면 처리 시 알림이 옵니다.
어디에 있나
9개 앱에 들어가 있습니다 — admin · hr-system · employee-app · team-manager · general-affairs · inventory · drive · management-dashboard · module-manager. 인증이 없는 landing 에는 없습니다.
층 지도
| z | 무엇 | 토큰 |
|---|---|---|
| 75 | 권한 팝업 | --ds-z-modal-system |
| 70 | 확인·알림 다이얼로그 | --ds-z-modal-alert |
| 68 | 신고 모달 | --ds-z-modal-report |
| 65 | 딤 위 신고 버튼 · 어시스턴트 도크 | --ds-z-report-fab · --ds-z-fab-ai |
| 60 | 모달 위 모달 | --ds-z-modal-top |
| 50 | 모달 + 딤 | --ds-z-modal |
| 44 | 채팅 FAB·패널 | --ds-z-fab |
| 40 | 페이지 헤더(헤더 안 신고 버튼) | --ds-z-page-header |
68과 65 사이가 이 컴포넌트의 자리입니다. 아래로 내리면 팝업에 깔리고, 위로 올리면 결정을 묻는 팝업을 가립니다.
화면 두 장
phase | 화면 | 푸터 |
|---|---|---|
form | 스크린샷 → 유형 → 제목 → 설명 → 파일 → 자동 첨부 안내 → 회신 동의 | 취소 · 보내기 |
done | 접수번호 + 복사 | 닫기 |
스크린샷의 네 상태
screenshotState | 화면 | 비고 |
|---|---|---|
capturing | 영역 링 .ds-spinner | 버튼 안이 아니라 영역 로딩 → 스피너 |
ready | 미리보기 캔버스 + 드래그 가림 | 「다시 찍기」·「제외」 |
failed · none | .ds-notice--info 안내 | 「파일로 추가해 주세요」 |
전송 중 버튼 안의 로딩은 아이콘 스피너(ic-spinner)입니다 — 두 종류를 섞지 않습니다.
이 화면이 쓰는 DS 부품
일반 모달 · 세그먼트(유형 4종) · 노티스(--info 안내 / --danger 전송 실패) · 스피너 두 종류 · 스위치 · 툴팁. 새로 그린 것은 딤 위 버튼 하나뿐입니다.
붙이는 법
컴포넌트 등록은 공용 플러그인이 이미 합니다. 앱은 index.html 에 호스트 한 줄 + include 한 줄만 놓으면 돼요.
<!-- apps/<app>/index.html — 앱 셸 안, 라우팅 밖 -->
<div x-data="csReport()" data-cs-report-host>
<!-- include "../../packages/components/html/cs-report.html" -->
</div>⚠️ data-cs-report-host 는 장식이 아닙니다. 두 가지가 이 속성에 걸려 있어요.
- 캡처 제외 — 이 속성이 없으면 신고 모달이 자기 자신을 찍습니다.
- 헤더 버튼 노출 —
PageHeader가 이 요소를 찾아보고 없으면 버튼을 감춥니다(죽은 버튼 방지).
전제
- SDK 플러그인에
appId를 넘겨야 한다. 스토어의csAppId가 비면 전송 단계에서 「앱 설정(appId)이 없어 리포트를 보낼 수 없어요」로 막힌다. - 로그·환경 수집기는 SDK 가 이미 돌린다(
$store.hereby.csReport). 컴포넌트는 필요할 때 함수로 읽기만 한다 — 수집기 배열을 스토어에 직접 담으면 Alpine 이 reactive 로 감싸push마다 재렌더된다. html-to-image는 동적 import 다. 초기 번들에 들어가지 않으므로 신고를 처음 열 때 한 번 받아온다.
여는 법
// 앱 어디서든
window.dispatchEvent(new CustomEvent("hereby:cs-report-open"));오류 다이얼로그를 직접 띄우는 코드라면, 다이얼로그가 화면을 덮기 직전 예비 캡처를 쏘면 됩니다(공용 showError 는 이미 그렇게 합니다).
window.dispatchEvent(new CustomEvent("hereby:cs-report-precapture"));상태·메서드
| 이름 | 종류 | 무엇 |
|---|---|---|
isOpen · phase | 상태 | 열림 / form | done |
type | 상태 | bug · inconvenience · feature_request · question |
screenshotState | 상태 | none · capturing · ready · failed |
includeLogs · notifyReporter | 상태 | 로그 첨부 / 결과 알림 동의 (기본 켬) |
errorSnapshot | 상태 | 수집기의 마지막 오류(5초 TTL) — 없으면 유형이 inconvenience 로 시작 |
autoInfo | getter | 「함께 전송되는 정보」에 보여 줄 실제 값 |
open() · close() | 메서드 | 이벤트·단축키가 부른다. 열 때 폼을 초기화한다 |
retake() · removeScreenshot() | 메서드 | 다시 찍기 / 제외 |
maskStart/Move/End() | 메서드 | 드래그 가림 — maskEnd 가 마스터 캔버스에 굽는다 |
onFilesPicked(e) · removeFile(i) | 메서드 | 최대 3개 · 각 10MB |
submit() | 메서드 | 첨부 업로드(presigned PUT) → csReport.create |
copyReportNumber() | 메서드 | 실패해도 조용히 무시(번호는 화면에 남는다) |
순수 함수 (테스트 대상)
| 함수 | 무엇 | 회귀 테스트 |
|---|---|---|
captureFilter(node) | 캡처에서 뺄 노드 판정 | cs-report-capture.test.ts |
shouldSkipCaptureImage(...) | 인라인 불가 이미지 판정(교차 출처·로드 실패) | 〃 |
sanitizeCaptureXml(xml) | Alpine 속성·-- 주석 제거 | 〃 |
markScrolledElements(doc) · restoreScrollInClone(parsed) | 스크롤 위치 기록 → 클론에 스냅 마커 | 〃 |
replaceEmbedFramesInClone(parsed, label, colors) · embedFramePath(src, title) | iframe 자리를 회색 상자로 · 컨텍스트에 남길 경로 | 〃 |
isReportShortcut(e) | Shift+F1 | CsReport.test.ts |
이것들은 전부 「틀려도 안 터지는」 로직입니다 — 필터가 과하면 그림이 조용히 비고, XML 정리가 빠지면 이미지가 조용히 실패하고, 스크롤 복원이 빠지면 보던 셀이 조용히 없어집니다. 옛 필터를 되살리면 정확히 두 케이스만 깨지는 것까지 테스트가 고정해 두었어요.
상수
| 이름 | 값 | 왜 |
|---|---|---|
CAPTURE_MAX_EDGE | 1920 | 긴 변 상한 |
CAPTURE_TIMEOUT_MS | 10,000 | 3초면 정상 캡처가 전부 실패 폴백으로 빠짐(실측 6.3초) |
PRECAPTURE_TTL_MS | 60,000 | 오류를 읽고 버튼을 누르기까지의 여유 |
MAX_USER_FILES · MAX_USER_FILE_BYTES | 3 · 10MB | 첨부 상한 |
data-testid
cs-report-fab · cs-report-modal · cs-report-close · cs-report-retake · cs-report-preview · cs-report-title · cs-report-desc · cs-report-auto-info · cs-report-include-logs · cs-report-notify · cs-report-submit · cs-report-number
단일 출처
- 로직 —
packages/components/src/components/CsReport.ts - 마크업 —
packages/components/html/cs-report.html(모달 + 딤 위 버튼이 한 파일) - CSS —
packages/static/styles/design-system.css(.ds-report-fab,.ds-modal-backdrop--report) — 앱 복사본 없음 - 서버 —
packages/api/src/routers/cs-report.router.ts· 운영자 화면은tools/system-admin - 아이콘 —
ic-megaphone
변경 이력
- 2026-09-14 — 스크롤 위치 복원(스냅 마커) · 임베드 iframe 회색 처리 +
embedFrames컨텍스트 + 안내 노티스 · 화이트리스트에translate/rotate/scale·line-clamp 추가 (온보딩 9/9 「가림/누락」 CS) - 2026-09-09 — 이 페이지 신설. 채팅 문서에 얹혀 있던 신고 FAB·캡처 규칙을 여기로 옮기고 층 Playground 를 붙였습니다 → 변경 이력
- 2026-09-04 — 딤 위 버튼(65) 도입 · 캡처 필터를 두 대상으로 축소 · ESC 공용 디스패처 등록
- 2026-09-02 — 캡처 성능 가드(숨김 서브트리 · 스타일 화이트리스트) · 제한 시간 3초 → 10초
남은 것
- 유형 타입이 마크업보다 좁습니다. 세그먼트는 네 갈래(
bug·inconvenience·feature_request·question)를 주는데 컴포넌트의type유니온에는feature_request가 빠져 있어요. 런타임은 정상이지만(서버CsReportType에는 있음) 타입만 보면 「기능 요청」이 없는 것처럼 읽힙니다 — 유니온을 손으로 적지 말고CsReportType을 쓰는 편이 맞습니다. - 모바일 화면 검증 — employee-app 에도 들어가 있지만 좁은 화면에서의 미리보기·드래그 가림은 아직 정리된 규칙이 없습니다.