ChatWidget
준비 중
ChatWidget 컴포넌트의 API·호출 예시·실제 사용처를 정리할 예정입니다. 아래 「플로팅 버튼(FAB) 배치 계약」 절은 확정된 규칙입니다.
플로팅 버튼(FAB) 배치 계약
채팅 FAB(chat-widget.html)와 AI FAB(ai-assistant.html)는 화면 우하단에 뜨는 한 세트다. 드로어·합계 줄 같은 화면 요소와 겹치지 않기 위한 배치 규칙이 공용 컴포넌트에 박혀 있다.
규칙 (강제)
- 기본 위치 — 두 FAB 모두
bottom-12(시트 합계 줄과 겹치지 않는 높이, 전 앱 일괄). 가로는 자리가 둘뿐이다: 바깥(가장자리24px)과 안쪽(80px). AI FAB이 바깥, 채팅 FAB이 안쪽이다. 각 패널은 자기 FAB와 같은 가로 오프셋을 쓴다. - 가로 위치는 마크업이 정하지 않는다 —
.ds-fab--ai/.ds-fab--chat만 붙이고design-system.css가 슬롯을 고른다. 채팅은 AI FAB이 실제로 DOM에 있을 때만 안쪽으로 물러나고(body:has(.ds-fab--ai)), 혼자일 때는 가장자리에 붙는다. 채팅은 7개 앱에 있고 AI는 admin 한 곳에만, 그것도 조직 AI가 켜져 있을 때만(x-if="enabled") 그려지므로 — 채팅을80px에 못 박으면 나머지 화면에서 가장자리가 빈 채 FAB 하나만 안쪽에 떠 있게 된다.right-[calc(…)]임의값을 마크업에 다시 쓰지 말 것. - 색은 둘이 갈린다 — AI = 브랜드 그라디언트(
.ds-fab--brand= BI 패밀리 3-stop, 색상 문서), 채팅 = 단색 다크(bg-neutral-900, 열림·hoverbg-neutral-700). AI FAB은 열림/닫힘으로 면 색을 바꾸지 않는다 — 상태는 아이콘(스파클↔셰브론)과 바로 위에 뜨는 패널이 진다. 미확인 배지(red)가 다크 면 위에 얹히므로 채팅 쪽이 배지 대비를 가져간다. - iframe 안에서는 두 FAB을 그리지 않는다 — admin은 team-manager·drive·시트를 iframe으로 품는데 끼워지는 앱들도 자기 화면에서 채팅 FAB을 그려, 한 화면에 같은 FAB이 두 벌 뜬다. 공용 플러그인이
<html class="ds-embedded">를 세우고 CSS가.ds-fab--chat·.ds-fab--ai만 숨긴다. ⚠️.ds-fab-layer전체를 숨기면 안 된다 — 시트 워크북의 레일 패널이 같은 레이어 클래스를 쓰므로 임베드된 시트가 통째로 깨진다. - 미확인 배지는 채팅 FAB에만 있고, 둘이 나란히 설 때 왼쪽 위로 넘어간다 — 기본은 오른쪽 위(
-top-1 -right-1)지만 그 자리가 두 버튼 사이 12px 틈을 향해, 배지가 옆의 AI 버튼에 달린 것처럼 읽힌다(실제 화면에서 확인된 오독).body:has(.ds-fab--ai) .ds-fab--chat > .ds-fab__badge가 왼쪽 위로 넘긴다. 채팅이 혼자일 때는 옆에 아무것도 없으므로 기본 그대로다. - 딤 위 신고 버튼
.ds-report-fab은 세 번째 상시 FAB이 아니다 — 팝업이 떠 있을 때만 뜬다(body:has(.ds-modal-backdrop:not([style*="display: none"]))). 팝업이 뜨면 헤더의 「불편·기능 신고」가 딤에 가려 못 눌리는데, 정작 팝업이 문제일 때가 신고가 가장 필요한 순간이라 그때만 여는 대체 진입점이다. 층은 어시스턴트와 같은 65 — 55 로 두면 겹쳐 뜬 모달(60)(admin 「근무 설정」 패널 위 「휴가/연차 유형」 같은 흔한 조합)의 딤에 그대로 깔린다. 보임/숨김을 CSS:has()로 판정하는 이유: MutationObserver 는 admin(2만 노드)에서 x-show 토글마다 돌아 버튼 하나 값어치를 넘는다. .ds-report-fab의 면은 흰색 + 어두운 글리프다. 채팅(단색 다크)·AI(그라디언트)와 셋이 세로로 늘어서므로 면으로 구분된다. 테두리(--color-line-normal)는 장식이 아니다 — 흰 원이 딤 위에 뜨면 경계가 뭉개진다..ds-report-fab에는data-ds-tip="top"을 명시한다. 툴팁 싱글톤은.ds-btn--icon·.icon-btn만 자동으로 잡는데 이 버튼은 둘 다 아니라, 빠뜨리면 브라우저 기본 툴팁이 뜬다. 라벨은title이 지고data-ds-tip은 위치만 고른다 → 툴팁- ⚠️
<button>이 아니라<div role="button">이다. 전역 blanket 규칙이 모든<button>의border-radius를 4px 로 강제해서, 버튼으로 두면 원이 아니라 모난 사각으로 렌더된다(실측). 채팅·AI FAB 도 같은 이유로 div 다 → radius. 대신 키보드 조작(role/tabindex/Enter·Space)은 직접 달아야 한다. - ⚠️
.ds-report-fab은 FAB 줄이 아니라 한 칸 위(bottom: 104px)에 선다. 가로 계약이 AI FAB 과 같아 같은 줄에 두면 좌표가 정확히 겹친다(실측 둘 다x=1532). 둘 다 65 라 겹치면 그리는 순서가 승부를 가르는데, 그건 include 순서에 따라 화면마다 달라진다 — 눈에는 안 보이고 클릭만 엉뚱한 데로 가는 고장이다. 세로로 한 칸 띄워 애초에 겹치지 않게 한다. - AI FAB의 알림은 숫자가 아니라 점 하나(
.ds-fab__dot)다 — 어시스턴트에는 「미확인 N건」이라는 개념이 없다. 있는 것은 닫아 둔 사이 답변이 도착했다는 사실뿐이고, 그건 세는 게 아니라 있고 없고다. 그래서 채팅의 숫자 배지(.ds-label)를 쓰지 않는다. 흰 링(box-shadow)은 장식이 아니라 필수 — AI FAB 면이 주황·빨강 그라데이션이라 링이 없으면 빨간 점이 면에 묻힌다. 켜지는 시점은 응답이 끝났는데 패널이 닫혀 있을 때, 꺼지는 시점은 패널을 열 때다(닫을 때 끄면, 닫는 순간 도착한 답변을 놓친다). - 비킴 변수
--ds-fab-dodge— FAB·패널의right는 전부calc(<기본값> + var(--ds-fab-dodge, 0px))다. 기본 0px 라 변수를 세우지 않는 앱은 아무 변화가 없다. - 오른쪽 드로어/레일을 여는 화면은 열려 있는 동안
--ds-fab-dodge: <드로어 폭>을body(또는 FAB 래퍼)에 세워 FAB 를 드로어 왼쪽으로 비켜 세운다. FAB 를 직접 옮기는 CSS 를 앱에 새로 쓰지 말 것 — 이 변수 하나가 유일한 조정 축이다. (첫 사용처: 급여 시트 워크북 — 레일 드로어 320px 가 열리면body.sheet-rail-open이 320px 를 세운다.) - iframe 임베드에서는 부모 문서의 FAB 를 함께 옮겨야 한다 — iframe 안 CSS 는 부모 FAB 에 닿지 않는다. 시트 워크북은
hereby:tm:railpostMessage 를 보내고, admin 이 수신해 FAB 래퍼의:style로 변수를 세운다. 새 임베드 화면도 같은 채널을 쓴다. - z 레이어는
.ds-fab-layer(--ds-z-fab44) — 모달(50)보다 아래여야 한다. Tailwindz-50을 붙이면 모달과 같은 값이라 나중에 그려지는 FAB 가 팝업 위를 덮는다.z-*유틸을 다시 붙이지 말 것. - AI 어시스턴트만 예외로 모달 위에 선다(
.ds-fab-layer.ds-fab--ai→--ds-z-fab-ai65). 어시스턴트는 팝업이 아니라 상시 크롬이라 모달을 띄운 채 "이 화면 뭐예요"를 물을 수 있어야 하는데, 44에서는 딤 아래로 내려가 통째로 못 썼다. 위 칸은 컨펌 다이얼로그(70)·권한 팝업(75)이 가져간다 — 결정을 묻는 팝업이 가려지면 안 되고, 어시스턴트가 스스로 띄우는 컨펌도 이 순서라야 보인다. ⚠️--ds-z-fab토큰을 올려서 해결하지 말 것 — 그 토큰은 채팅 FAB·패널과 시트 워크북의 수식 범위 바가 함께 쓴다. 값을 올리면 2026-08에 고쳤던 "FAB이 모달의 확정 버튼을 덮는다"가 그대로 돌아온다. 예외는 요소 단위(더블 클래스)로만 준다. - 말풍선 본문은 두 대화창 모두
text-b5(13px) 다. ⚠️ 어시스턴트의 마크다운 문단에는font-size: inherit이 반드시 있어야 한다 — 공유 DS 의 unlayeredp규칙이 유틸을 이겨서, 없으면 문단만 16px 로 뜬다(실측). 사용자 말풍선은<div>라 13px 그대로여서 어시스턴트 글자만 커 보인다. 한 화면에 나란히 뜨므로 글자가 다르면 바로 눈에 띈다 — 한쪽만 바꾸지 말 것. 어시스턴트의 마크다운(.ai-md)은 이 값에 물려 있어 본문을 바꾸면 그 블록을 통째로 다시 잡아야 한다(제목 b4 > 본문 b5 > code b6).text-xs(12px)로 내리지 않는다 — Tailwind 기본 스케일이라 DS 토큰 밖이고, 보조 텍스트(b6)보다 작아져 위계가 뒤집힌다. - 응답 피드백(👍/👎)은 감춰 뒀다(2026-09-04).
ai.feedback로 저장은 되는데 읽는 곳이 한 군데도 없어(운영자 화면도 집계도 없음) 사용자가 누른 값이 아무 데도 쓰이지 않는다. 쓰는 곳이 생기면 마크업을 되살린다 — 컴포넌트의rate()는 남겨 뒀다. - ESC 로 닫히는 것은 채팅 패널과 「떠 있는」 어시스턴트뿐이다 — 도크는 닫히지 않는다. 떠 있을 때는 본문 위에 얹힌 팝업이라 ESC 가 자연스럽지만, 도크는 본문을 밀어내고 자리를 차지하는 상시 크롬이라 ESC 의 대상이 아니다 — 모달을 띄운 채로도 쓰라고 만든 것을 키 하나로 접으면 그 전제가 깨진다. 판정은 층 등록의
getEl이 진다: 도크일 때null을 돌려 후보에서 빠진다. - ESC 는 「화면에서 가장 위에 있는 것」 하나만 닫는다 — 판정은
esc-guard.ts의 공용 디스패처 한 곳이 한다. 팝업마다@keydown.escape.window를 달면 먼저 등록된 리스너가 먼저 도는데, 그 순서는 Alpine 이x-data를 만나는 순서(DOM 순서)라 늦게 뜬 팝업과 무관하다 — 실제로 모달 위에 신고 팝업을 띄우고 ESC 를 누르면 밑에 깔린 모달이 닫혔다(2026-09-04 사용자 리포트). - 새 팝업은
registerEscLayer(getEl, close)로 등록하고, 마크업의@keydown.escape.window는 뺀다 — 두 벌이면 다시 순서 싸움이다. 디스패처는 window 캡처 단계에 하나만 걸려 어떤 버블 리스너보다 먼저 돌고, 고른 하나를 닫은 뒤 전파를 끊는다. - 「가장 위」는 시간이 아니라 z-index 로 고른다. 팝업은 나중에 뜰수록 높은 층에 서므로 결과가 같고, 여는 시각을 기록할 필요가 없다. 등록 안 한(레거시) 모달이 더 위면 아무것도 하지 않고 비켜서 그 모달의 자기 리스너가 종전대로 처리한다 — 전 모달을 한꺼번에 고칠 필요가 없다. 권한 팝업(75)은 자기 리스너가 있어
permissionModalOwnsEsc()로 먼저 양보한다. - ⚠️ 표시 여부를
offsetParent로 재지 말 것 — 백드롭은position: fixed라 항상 null 이다. computeddisplay로 본다. - 바에 놓인 버튼은 텍스트든 아이콘이든 툴팁을 단다 — 라벨은
title이 지고data-ds-tip은 위치만 고른다. 아이콘 버튼(.ds-btn--icon)은 툴팁 셀렉터에 자동으로 걸리지만 텍스트 버튼은 안 걸리므로data-ds-tip="top"을 명시해야 한다.aria-label만 있으면 툴팁은 뜨지 않는다 → 툴팁
AI 어시스턴트 도크 (우측 스티키 패널)
어시스턴트는 플로팅 패널과 화면 오른쪽에 붙는 도크 두 모드로 뜬다. 도크는 화면을 덮지 않고 본문을 밀어낸다. AI FAB을 누르면 바로 도크로 열리며, 표시 방식을 고르는 토글은 없다.
규칙 (강제)
- 모드는 클래스 하나(
.ds-ai-dock)로만 갈린다 — 마크업을 두 벌 만들지 말 것. 플로팅과 도크는ai-assistant.html의 같은 요소다.x-if로 갈래를 나누면 Alpine 인스턴스가 갈려 전환할 때마다 대화가 통째로 초기화된다. 도크 클래스가bottom-26·w-96·h-[560px]·rounded-16·shadow-popover유틸을 되덮는데, 유틸은@layer utilities안이고 DS 규칙은 레이어 밖이라 특이도와 무관하게 이긴다. - 폭의 정본은
--ds-ai-dock-w하나다. 본문 밀어내기(body.ds-ai-dock-open의padding-right) · 모달 딤의 오른쪽 여백 · 채팅 FAB 비킴이 모두 이 변수 하나를 읽는다. 세 곳에 각각 바인딩을 걸지 말 것 — 드래그 한 프레임마다 Alpine이 패널 서브트리를 다시 그려, 임베드된 시트가 매 프레임 가상화 그리드를 재계산한다. - 본문 밀어내기는
.app-shell이 아니라<body>에 건다. 도크는 공용 컴포넌트인데 셸 클래스는 앱마다 다르다.position: fixed인 모달·토스트·FAB은 이 패딩을 무시하므로 각자 따로 비킨다(아래 두 항목). - 폭은
360 ~ min(800, 화면폭 − 720). 화면이 좁아지면 상한이 함께 줄어든다(실측: 1920·1700→800 · 1500→780 · 1400→680 · 1280→560). ⚠️ 줄어들 때 「고른 폭」을 덮어쓰면 안 된다 — 표시만 물리고 사용자가 고른 값은 그대로 둔다(dockRenderWidthvsdockWidth). 예전에는 창을 줄일 때마다 고른 값 자체를 물려서 다시 넓혀도 안 돌아왔다(800 → 900px 로 줄였다 되돌리면 360 으로 굳음, 실측). 상한을 800에 못 박으면 활성 하한인 1280 화면에서 본문이 272px만 남아 표가 통째로 못 읽히게 된다 — PC 경계를lg→xl로 올린 것과 같은 문제다(브레이크포인트). 하한 360은 실측이다: 패널 헤더가 타이틀까지 세우는 데 336px가 필요하다. - 태블릿 이하(<1280)에서는 도크 자체가 없다. 화면이 좁아지면 떠 있는 패널로, 다시 넓히면 도크로 자동 전환된다 — 고를 여지가 없으므로 토글도 없다(바로 아래 항목).
- 어시스턴트는 도크로만 열린다 — 떠 있는 창 모드는 없다. 표시 방식을 고르는 토글도 두지 않는다. 이유는 채팅 패널과의 겹침이다: 384px짜리 둘을 나란히 띄우면 80%가 겹쳐(실측 328×560) 뒤쪽을 읽을 수 없는데, 순서를 z로 바꾸는 것은 불가능하다 — 어시스턴트는 모달(50) 위(65)여야 하고 채팅은 모달 아래(44)여야 해서, 채팅을 어시스턴트 위로 올리면
채팅 > 65 > 50이 되어 모달의 확정 버튼을 덮는다(2026-08 회귀). 도크는 본문을 밀어내고 채팅이 그 왼쪽으로 비키므로 겹침 자체가 0이 된다 — z를 하나도 건드리지 않고 풀리는 유일한 길이다. - 1280 밑으로 내려가는 순간 어시스턴트는 접힌다. 떠 있는 창으로 바꿔 두면 그 폭에서 채팅과 80% 겹쳐 뒤쪽을 못 읽는다 — 열어 둔 채 두는 것보다 접는 편이 낫다. 경계를 넘는 순간에만 접는다: 좁은 화면에서 사용자가 직접 연 떠 있는 창까지 리사이즈마다 닫으면 못 쓴다.
- 떠 있는 상태에서는 채팅과 어시스턴트가 한 번에 하나만 열린다(UI 겹침 방어). 여는 쪽이
hereby:floating-panel-open을 쏘고 반대쪽이 접는다. 도크에서는 쏘지 않는다 — 채팅이 도크 왼쪽으로 비켜서 겹치지 않으므로 둘 다 열려 있어도 된다(실측 겹침 0). - 헤더의 X는 「닫기」가 아니라 「도크 해제」다. 대화를 끝내는 게 아니라 패널을 원래의 플로팅 버튼(FAB)으로 되돌릴 뿐이고, 대화는 같은 인스턴스에 남아 FAB을 다시 누르면 이어진다 — 그래서 라벨도 「닫기」가 아니다(
undock(),close()가 아님). 도크에서는 FAB이 비노출이라 이것이 유일한 출구이므로 선택이 아니라 필수다. x-show로 숨은 FAB은 DOM에는 남는다 — 그래서 채팅 FAB의 가로 슬롯을 정하는body:has(.ds-fab--ai)가 계속 매치한다.body.ds-ai-dock-open .ds-fab-layer.ds-fab--chat(0,3,1)이 특이도로 이 규칙(0,2,1)을 이겨 바깥 슬롯(24px)으로 되돌리고, 배지도 기본(오른쪽 위)으로 되돌린다. 소스 순서에 기대지 말 것.- 채팅 FAB의 비킴은 도크와 드로어를 더한다 —
calc(24px + var(--ds-ai-dock-w) + var(--ds-fab-dodge)). 도크와 시트 레일은 동시에 열릴 수 있다. 둘 중 하나를 통째로 대입하는 형태로 쓰지 말 것. - 도크가 열리면 모달 딤의 오른쪽을 도크 폭만큼 물린다(
body.ds-ai-dock-open .ds-modal-backdrop)..ds-modal-backdrop은inset: 0+justify-content: center라 도크를 몰라 카드를 뷰포트 정중앙에 세우는데, 도크(--ds-z-fab-ai65)가 모달(50)보다 위라 카드가 도크에 덮여 확인 버튼을 못 누르게 된다. ⚠️ z를 내려서 해결하지 말 것 — 65는 "모달을 띄운 채 어시스턴트에게 묻기"를 위해 올린 값이다(위 항목 참고). - 드래그 중에는
iframe의 포인터 이벤트를 죽인다(body.ds-ai-dock-resizing iframe). admin은 급여·근태·드라이브·팀관리를 iframe으로 품는데, 커서가 iframe 위로 들어가는 순간 포인터 이벤트가 자식 문서로 가서 부모가 못 받고 드래그가 그 자리에 굳는다. 같은 이유로user-select도 함께 잠근다(안 하면 드래그가 본문 텍스트 선택으로 샌다). - 폭 조절 핸들은 키보드로도 움직여야 한다 —
role="separator"+aria-orientation="vertical"+aria-valuenow/min/max, ←/→ 16px, Home으로 기본 폭. 마우스 전용 핸들은 접근성 결손이다. - 모드 판별은
data-dock="on|off"로 한다 — 플로팅과 도크가 같은 요소라ai-panel의 존재만으로는 구분되지 않는다(e2e 포함).
캡처는 화면에 보이는 것을 그대로 담는다
CS 리포트 캡처(CsReport.ts)가 빼는 것은 신고 UI 자신(data-cs-report-host)과 확인·알림 다이얼로그(--top) 둘뿐이다. 예전에는 .ds-fab-layer 를 통째로 걸러 채팅·어시스턴트 패널과 시트 레일이 그림에서 빠졌는데, 어시스턴트가 문제일 때가 신고가 가장 필요한 순간이라 정작 증거가 사라졌다(2026-09-04 사용자 리포트). 도크가 열려 있으면 밀려난 본문과 도크가 함께 찍힌다 — 오른쪽을 잘라내지 않는다.
두 FAB 는 transition-all 이 걸려 있어 변수가 바뀌면 부드럽게 미끄러진다. 드로어가 닫히면 변수를 지워(또는 클래스를 내려) 제자리로 돌아온다.