Skip to content

현재 사용법 (2026-09-10, 6fbaee7f): AI 질문·대화 이력·도킹 · 기능 마스터. 아래는 작성 당시의 변경 설명이며 현재 전체 동작·재측정 결과를 뜻하지 않습니다.

AI 어시스턴트 도크와 딤 위 신고 버튼 ​

2026-09-04 · 브랜치 design/ai-assistant-dock

AI 어시스턴트를 화면 위에 떠 있는 창이 아니라 오른쪽에 붙는 패널로 열어 본문을 옆으로 밀어냅니다. 그리고 팝업이 떠 있는 동안에도 「불편·기능 신고」를 누를 수 있도록, 팝업을 덮는 어두운 막 위에 신고 버튼을 따로 띄웁니다.

세 갈래의 근본 원인은 하나입니다 ​

어시스턴트가 채팅을 가리는 문제, 팝업이 신고 버튼을 가리는 문제, ESC 가 엉뚱한 창을 닫는 문제는 근본 원인이 동일합니다. 화면에 동시에 뜨는 것들의 앞뒤 순서를 z-index 값 하나로 정하려 한 것입니다. z-index 는 화면 전체가 공유하는 하나의 사다리라서, 한 쪽을 올리면 그 위에 있어야 할 다른 쪽이 함께 깨집니다. 이 PR 은 셋 다 사다리를 다시 세우는 대신 애초에 겹치지 않게 배치를 바꾸거나, 순서 판정을 한 곳으로 모으는 방식으로 풀었습니다.

배경 — 지금 화면에 무엇이 떠 있습니까 ​

admin 화면 오른쪽 아래에는 동그란 버튼 두 개가 떠 있습니다. 이하 이것을 FAB(floating action button, 플로팅 버튼)이라고 씁니다. 바깥쪽 24px 자리에 AI 어시스턴트 FAB 이, 안쪽 80px 자리에 채팅 FAB 이 섭니다. 각각을 누르면 그 위로 384px 폭의 패널이 열립니다.

팝업(모달)이 열리면 화면 전체가 반투명 검정으로 덮입니다. 이하 이 검은 막을 딤이라고 씁니다. 딤은 뒤쪽 화면의 클릭을 막는 것이 역할이므로, 딤보다 아래에 있는 것은 보이기만 하고 눌리지 않습니다.

세 가지가 문제였습니다.

첫째, 어시스턴트 패널과 채팅 패널이 겹쳤습니다. 둘 다 384px 폭으로 같은 모서리에서 열려 실측 328×560 만큼, 곧 넓이의 80% 가 겹쳤습니다. 뒤쪽 패널은 읽을 수 없었습니다.

둘째, 팝업이 뜨면 헤더의 「불편·기능 신고」 버튼이 딤에 가려 눌리지 않았습니다. 그런데 신고가 가장 필요한 순간은 정확히 그 팝업이 잘못 동작할 때입니다.

셋째, ESC 를 누르면 최근에 뜬 것이 아니라 먼저 열려 있던 것이 닫혔습니다. 각 패널이 자기 @keydown.escape.window 리스너를 따로 달고 있어, 브라우저가 등록된 순서대로 리스너를 호출했기 때문입니다.

왜 「마지막에 누른 창을 위로」가 불가능합니까 ​

가장 먼저 검토한 방법은 두 패널 중 마지막에 클릭한 쪽을 위로 올리는 것이었습니다. 이 방법은 성립하지 않습니다.

어시스턴트는 팝업 위에서도 쓸 수 있어야 해서 모달(50)보다 위인 65 에 있습니다. 반면 채팅은 모달보다 아래여야 합니다. 2026-08 에 채팅 FAB 이 모달의 확정 버튼을 덮어 누를 수 없게 된 회귀가 있었고, 그래서 44 로 내려 둔 것입니다. 채팅을 어시스턴트 위로 올리려면 65 보다 큰 값을 줘야 하는데, 그러면 채팅이 모달(50)보다도 위가 되어 그 회귀가 그대로 돌아옵니다. 사다리에 빈 칸(41~43, 47~49, 61~64, 66~69)이 있지만 어디에 놓아도 「채팅 > 어시스턴트 > 모달」이라는 순서 자체가 모달을 덮습니다.

그래서 순서를 바꾸는 대신 겹침을 없앴습니다. 도크는 본문을 왼쪽으로 밀어내고 채팅 FAB 도 도크 폭만큼 비켜서므로, 두 패널이 겹치는 넓이가 0 이 됩니다.

변경 전

변경 후

도크 열림

왜 헤더 안의 버튼만 따로 올릴 수 없습니까 ​

신고 버튼은 페이지 헤더 줄 안에 있습니다. 이 버튼만 딤 위로 올리는 것이 자연스러운 해법으로 보입니다. 그러나 불가능합니다.

헤더 줄을 감싸는 요소에는 position: sticky 와 z-index 가 걸려 있습니다. 어느 요소든 z-index 나 position: sticky 를 가지면 그 안쪽에 쌓임 맥락(stacking context)이 생깁니다. 쌓임 맥락 안의 자식은 아무리 큰 z-index 를 받아도 그 맥락 전체가 가진 값 하나로 바깥과 겨루게 됩니다. 자식에게 position: fixed 를 줘도 결과는 같습니다.

헤더 줄 전체를 딤 위로 올리는 방법은 실제로 한 번 구현했다가 되돌렸습니다. 팝업이 떠 있는 동안 상단바만 밝게 남아 화면이 어색했기 때문입니다.

그래서 헤더와 무관하게 화면 최상위에 따로 그리는 버튼을 두었습니다. 이 버튼은 딤이 떠 있을 때만 나타납니다.

변경 전

변경 후

딤 위 신고 버튼

개념별 변경 내용 ​

1. 도크 — 폭의 정본은 CSS 변수 하나입니다 ​

패널 폭은 --ds-ai-dock-w 라는 CSS 변수 하나에만 씁니다. 본문의 오른쪽 여백과 채팅 FAB 이 비켜설 거리가 모두 이 변수를 읽습니다. 세 곳에 각각 Alpine 바인딩을 걸면 드래그하는 매 프레임마다 패널 하위 요소가 다시 그려지고, admin 이 품은 임베드 시트가 프레임마다 가상화를 재계산합니다.

폭은 360 부터 min(800, 화면폭 − 720) 까지입니다. 720 은 본문이 최소한 확보해야 하는 폭입니다. 여기서 사용자가 고른 값(dockWidth)과 지금 그릴 값(dockRenderWidth)을 분리했습니다. 한 값만 쓰면 창을 줄일 때 좁혀진 값이 원래 값을 덮어써서, 창을 다시 넓혀도 800 이 360 인 채로 돌아오지 않습니다.

도크 폭 조절

2. 1280 미만에서는 접고, 떠 있는 창은 하나만 엽니다 ​

화면 폭이 1280 미만이면 본문을 밀어낼 자리가 없으므로 도크를 접고 떠 있는 패널로 돌아갑니다. 이 상태에서는 앞서 말한 80% 겹침이 다시 발생하므로, 어시스턴트와 채팅 중 한 번에 하나만 열리게 했습니다. 하나를 열면 hereby:floating-panel-open 이벤트가 발생하고 다른 하나가 접힙니다.

ESC 도 떠 있을 때만 받습니다. 도크는 본문 옆에 놓인 상시 표시 요소라서 ESC 로 닫을 대상이 아닙니다.

1280 미만 플로팅

플로팅 겹침 방어

3. 딤 위 신고 버튼은 FAB 줄보다 한 칸 위에 섭니다 ​

가로 위치는 채팅·AI FAB 과 같은 계약(바깥 24px 에 도크 폭과 드로어 비킴을 더한 값)을 따르지만, 세로는 104px 로 한 칸 올렸습니다. 같은 줄에 두면 AI FAB 과 좌표가 정확히 겹칩니다. 실측으로 둘 다 x=1532 였고, AI FAB 이 더 위에 있어 클릭을 대신 받습니다. 화면상으로는 버튼이 정상으로 보이므로 발견하기 어려운 고장입니다.

버튼은 <button> 이 아니라 <div role="button"> 입니다. 전역 규칙 세 개가 모든 <button> 의 border-radius 를 4px 로 강제하고 있어, <button> 으로는 원형이 되지 않습니다. <div role="button"> 은 이 강제를 피하는 정식 경로이며 키보드 조작은 직접 처리합니다.

딤이 떠 있는지는 CSS :has() 로 판정합니다. JavaScript 감시(MutationObserver)를 쓰지 않은 이유는 admin 문서가 2만 노드가 넘고 x-show 토글마다 style 속성이 바뀌어, 관찰 비용이 이 버튼 하나의 값어치를 넘기 때문입니다.

4. 신고 모달은 겹쳐 뜬 모달 위에 열립니다 ​

신고를 부르는 화면은 이미 모달이거나 모달 위 모달인 경우가 흔합니다. 기본 백드롭 값(50)으로 두면 신고 모달이 그 아래로 들어가, 사용자에게는 「신고를 눌렀는데 아무 일도 일어나지 않는다」로 보입니다. --ds-z-modal-report 를 68 로 두어 겹쳐 뜬 모달(60)보다 위, 확인 다이얼로그(70)보다 아래에 놓았습니다. 확인 다이얼로그보다 아래인 것은 신고 모달 안에서 띄우는 확인 팝업이 자기를 덮을 수 있어야 하기 때문입니다.

신고 모달이 팝업 위에 열림

5. ESC 판정을 한 곳으로 모았습니다 ​

각 패널이 자기 리스너를 다는 대신, 공용 디스패처(utils/esc-guard.ts)가 캡처 단계에서 한 번만 판정합니다. 등록된 층 가운데 z-index 가 가장 높은 하나만 닫고, 등록되지 않은 기존 백드롭이 더 위에 있으면 그쪽에 양보합니다.

여기서 보이는지 여부는 display 로 판정해야 합니다. 이전 구현은 offsetParent 가 null 인지로 판정했는데, position: fixed 요소의 offsetParent 는 항상 null 입니다. 백드롭은 모두 position: fixed 이므로 이 검사는 통째로 무력했고, 채팅 패널의 ESC 양보 로직이 실제로 동작하지 않는 상태였습니다.

6. 캡처에서 무엇을 빼는지 회귀로 고정했습니다 ​

신고 모달은 현재 화면을 자동으로 캡처해 첨부합니다. 이 캡처의 제외 목록(captureFilter)은 빼는 대상이 하나 늘 때마다 신고 화면이 조용히 비어 갑니다. 실제로 하루에 두 번 회귀했습니다. .ds-modal-backdrop 을 제외했더니 신고하려던 팝업 자체가 찍히지 않았고, .ds-fab-layer 를 제외했더니 어시스턴트 패널이 찍히지 않았습니다. 두 경우 모두 예외도 로그도 발생하지 않아 사람이 스크린샷을 열어봐야 알 수 있었습니다.

지금 제외하는 것은 신고 UI 자신(data-cs-report-host)과 확인 다이얼로그(ds-modal-backdrop--top) 둘뿐입니다. cs-report-capture.test.ts 가 남길 것과 뺄 것을 회귀로 고정하며, 옛 필터를 되살리면 남겨야 할 두 경우에서만 정확히 깨지는 것을 확인했습니다.

위 5번 그림의 캡처 미리보기 안에 「직원 추가」 팝업이 그대로 들어가 있는 것이 이 수정의 결과입니다.

검증 ​

admin(4201)과 API(3300)를 띄우고 Playwright 로 실화면을 측정했습니다. 도크 폭은 384 에서 드래그 후 800 으로 바뀌고 data-dock="on" 이 유지되었습니다. 1100px 에서는 data-dock="off" 로 바뀌고, 채팅을 열면 어시스턴트 패널 폭이 0 이 되었습니다. 팝업이 없을 때 신고 FAB 의 display 는 none, 팝업이 열리면 표시되었습니다. 열린 백드롭의 z-index 는 직원 추가 모달이 50, 신고 모달이 68 이었습니다.