Skip to content

채팅 위젯 (ChatWidget) ​

우하단 진입점에서 대화 패널이 뜨는 사내 채팅입니다. 진입점 배치 계약과 패널 안 모습을 확인해 보세요.

Alpine 컴포넌트 + DS CSS · Web · 진행 중(WIP)

Interactive playground ​

옵션을 바꾸면 미리보기와 코드가 함께 바뀌어요.
미리보기 테마
본문
대화
근무표에 반차 반영해 두면 될까요?
다음 주 목요일 오후 반차 쓸게요. 결재 올려 뒀습니다.
어두운 버튼이 채팅 진입점입니다 — 눌러 패널을 열고 닫아 보세요. 패널 안은 실제 .ds-bubble · .ds-composer 이고, 껍데기는 어시스턴트와 **같은** .ds-chat-panel 입니다. 「AI FAB 함께 세우기」를 꺼 보세요 — 채팅 FAB 이 가장자리로 돌아옵니다. 가로 자리는 마크업이 아니라 CSS 가 고르거든요 (body:has(.ds-fab--ai)). 미확인 배지도 둘이 나란히 설 때만 왼쪽 위로 넘어갑니다.
패널 안의 대화/연락처 탭·공지 목록은 일부러 그리지 않았습니다 — 그건 Messenger 파셜의 것이라, 여기 베껴 두면 그쪽이 바뀔 때 이 그림만 옛날에 남습니다.
<!-- 자리·층은 마크업이 정하지 않는다 — 클래스만 붙이고 CSS 가 슬롯을 고른다 -->
<div class="ds-fab-layer ds-fab--chat fixed bottom-12 w-11 h-11 rounded-full">
  <span class="ds-fab__badge absolute -top-1 -right-1">…</span>
</div>

<div class="ds-fab-layer ds-fab--chat ds-chat-panel fixed bottom-26">
  <!-- messenger-content.html include — 대화 목록 · 말풍선 · 입력줄 -->
</div>

/* 지금 이 조합: AI FAB 있음 → 채팅 FAB 은 80px (안쪽) */
/* body:has(.ds-fab--ai) 가 매치해 안쪽으로 물러나고, 배지는 왼쪽 위로 넘어간다 */

AI FAB 을 함께 세울지와 미확인 배지를 바꿔 가며, 채팅 FAB 의 가로 자리와 배지 위치가 어떻게 갈리는지 볼 수 있습니다 — 둘 다 마크업이 아니라 CSS 가 정하는 값이에요. 패널 안의 말풍선·입력줄은 대화 UI 의 부품이고, 그 축은 그쪽 페이지에 있습니다.

이 페이지가 다루는 것

진입점(FAB) 배치 계약과 위젯 껍데기입니다. 패널 안을 채우는 대화 목록·연락처·공지는 Messenger 가, 말풍선·입력줄 부품은 대화 UI 가, 옆에 서는 어시스턴트는 AiAssistant 가 가져갑니다.

플로팅 버튼(FAB) 배치 계약 ​

채팅 FAB(chat-widget.html)와 AI FAB(ai-assistant.html)는 화면 우하단에 뜨는 한 세트다. 드로어·합계 줄 같은 화면 요소와 겹치지 않기 위한 배치 규칙이 공용 컴포넌트에 박혀 있다.

규칙 (강제)
  • 기본 위치 — 두 FAB 모두 bottom-12(시트 합계 줄과 겹치지 않는 높이, 전 앱 일괄). 가로는 자리가 둘뿐이다: 바깥(가장자리 24px)과 안쪽(80px). AI FAB이 바깥, 채팅 FAB이 안쪽이다. 각 패널은 자기 FAB와 같은 가로 오프셋을 쓴다.
  • 스크롤 끝 여백 .ds-fab-safe(--ds-fab-clearance 108px) — FAB(바닥 위 48 + 높이 44 = 92)에 16 간격. 페이지의 맨 아래 스크롤 상자 안쪽에 붙인다: 평소엔 FAB 가 내용 위에 겹쳐 떠도 되고, 스크롤을 끝까지 내리면 마지막 줄·쪽 이동이 FAB 위로 올라와 온전히 보인다. 바깥(고정) 여백으로 두지 말 것 — 평소에도 화면 아래가 FAB 높이만큼 빈다. 모든 표(.tbl-outline-flow)에 일괄로 걸지 말 것 — 모달·페이지 중간의 표에도 108 이 생긴다. 넘치는 것이 감싸개가 아니라 안쪽 요소면(admin 설정 .settings-tab-org) 그 요소의 padding-bottom 을 var(--ds-fab-clearance) 로 둔다. FAB 위치(bottom-12 · h-11)를 바꾸면 이 값도 함께 바꾼다.
  • 가로 위치는 마크업이 정하지 않는다 — .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, 열림·hover bg-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 전체를 숨기면 안 된다 — 시트 워크북의 레일 패널이 같은 레이어 클래스를 쓰므로 임베드된 시트가 통째로 깨진다.
  • 예외는 하나 — 「임베드 화면을 맡는 어시스턴트」(.ds-fab-embed-owner). 부모가 자기 AI 위젯을 치우고(admin 의 suspended) 자식에게 화면을 맡길 때는 자식 쪽 AI FAB 이 떠야 한다 — 시트 컨텍스트를 아는 문서는 iframe 안(hr-system 「시트 어시스턴트」)뿐이다. 그 위젯의 래퍼에 이 클래스를 달면 AI FAB·패널만 숨김에서 빠진다(:not(.ds-fab-embed-owner *)). 부모가 감추는 결정과 자식의 이 클래스는 한 쌍이다: 한쪽만 있으면 AI 진입점이 하나도 없거나(2026-09-22 QA — 임베드 시트에 반짝이 버튼이 안 보임) 두 벌이 겹친다. 채팅 FAB 에는 예외가 없다 — 채팅은 화면 컨텍스트가 없어 부모 것 하나로 충분하다.
  • 미확인 배지는 채팅 FAB에만 있고, 둘이 나란히 설 때 왼쪽 위로 넘어간다 — 기본은 오른쪽 위(-top-1 -right-1)지만 그 자리가 두 버튼 사이 12px 틈을 향해, 배지가 옆의 AI 버튼에 달린 것처럼 읽힌다(실제 화면에서 확인된 오독). body:has(.ds-fab--ai) .ds-fab--chat > .ds-fab__badge가 왼쪽 위로 넘긴다. 채팅이 혼자일 때는 옆에 아무것도 없으므로 기본 그대로다.
  • 딤 위 신고 버튼 .ds-report-fab 은 세 번째 상시 FAB이 아니다 — 팝업이 떠 있을 때만 뜨는 대체 진입점이고, 이 세트와는 가로 자리 계약만 공유한다(24px + 도크 + 비킴). ⚠️ 세로는 FAB 줄이 아니라 한 칸 위(bottom: 104px) 다 — 같은 줄에 두면 AI FAB 과 좌표가 정확히 겹치는데(실측 둘 다 x=1532) 둘 다 65 라 눈에는 멀쩡한데 클릭만 엉뚱한 데로 가는 고장이 된다. 층·면·마크업(<div role="button">)·표시 조건은 → CsReport
  • 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:rail postMessage 를 보내고, admin 이 수신해 FAB 래퍼에 클래스(.sheet-rail-open → --ds-fab-dodge)로 건다. ⚠️ :style 문자열 바인딩으로 세우지 말 것 — Alpine 이 style 속성을 통째로 덮어써 x-show 의 display:none 까지 지운다(CS-OKBEST-260922-0002). 새 임베드 화면도 같은 채널을 쓴다.
  • 임베드 안에서 어시스턴트가 도크로 열리면 그 폭도 부모가 비켜야 한다 — 도크는 iframe 안에서만 본문을 밀어내므로 부모 채팅 FAB 은 그대로 도크 입력줄 위에 얹힌다. 공용 위젯이 hereby:tm:ai-dock({ open, width })을 부모에 보내고, admin 은 body 의 --ds-embed-ai-dock-w 로 세워 --ds-fab-dodge 에 더한다(레일과 동시에 열릴 수 있다). 자식 앱은 아무것도 하지 않는다 — 위젯이 applyDockLayout 에서 폭이 바뀔 때마다 보낸다.
  • z 레이어는 .ds-fab-layer(--ds-z-fab 44) — 모달(50)보다 아래여야 한다. Tailwind z-50 을 붙이면 모달과 같은 값이라 나중에 그려지는 FAB 가 팝업 위를 덮는다. z-* 유틸을 다시 붙이지 말 것.
  • AI 어시스턴트만 예외로 모달 위에 선다(.ds-fab-layer.ds-fab--ai → --ds-z-fab-ai 65). 어시스턴트는 팝업이 아니라 상시 크롬이라 모달을 띄운 채 "이 화면 뭐예요"를 물을 수 있어야 하는데, 44에서는 딤 아래로 내려가 통째로 못 썼다. 위 칸은 컨펌 다이얼로그(70)·권한 팝업(75)이 가져간다 — 결정을 묻는 팝업이 가려지면 안 되고, 어시스턴트가 스스로 띄우는 컨펌도 이 순서라야 보인다. ⚠️ --ds-z-fab 토큰을 올려서 해결하지 말 것 — 그 토큰은 채팅 FAB·패널과 시트 워크북의 수식 범위 바가 함께 쓴다. 값을 올리면 2026-08에 고쳤던 "FAB이 모달의 확정 버튼을 덮는다"가 그대로 돌아온다. 예외는 요소 단위(더블 클래스)로만 준다.
  • 말풍선과 입력줄은 두 대화창이 «같은 부품»을 쓴다(.ds-bubble · .ds-composer) — 한 화면에 나란히 뜨므로 한쪽만 바꾸지 말 것. 크기(b5)·꼬리·폭 축과 마크다운 문단의 font-size: inherit 함정은 → 말풍선 · 입력줄
  • ESC 로 닫히는 것은 채팅 패널과 「떠 있는」 어시스턴트뿐이다 — 도크는 닫히지 않는다. 떠 있을 때는 본문 위에 얹힌 팝업이라 ESC 가 자연스럽지만, 도크는 본문을 밀어내고 자리를 차지하는 상시 크롬이라 ESC 의 대상이 아니다 → AiAssistant
  • 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 이다. computed display 로 본다.
  • 바에 놓인 버튼은 텍스트든 아이콘이든 툴팁을 단다 — 라벨은 title이 지고 data-ds-tip은 위치만 고른다. 아이콘 버튼(.ds-btn--icon)은 툴팁 셀렉터에 자동으로 걸리지만 텍스트 버튼은 안 걸리므로 data-ds-tip="top"을 명시해야 한다. aria-label만 있으면 툴팁은 뜨지 않는다 → 툴팁

브라우저 탭 알림 ​

채팅에 새 메시지가 왔는데 아직 확인하지 않았으면, 브라우저 탭 제목이 「신규 메시지가 도착했습니다.」로 바뀌고 파비콘이 움직인다. 메시지가 도착하는 순간에는 채팅 FAB 도 한 번 통통 튄다. 다른 탭·다른 창을 보고 있을 때뿐 아니라, 이 탭을 열어 두고 채팅을 안 봤을 때도 뜬다. 그 메시지를 확인하면 원래 제목·파비콘으로 돌아온다. 판정은 ChatWidget.ts 의 syncTabAttention(), 판정 규칙은 unread-arrivals.ts, 제목·파비콘 교체는 tab-attention.ts 가 맡는다.

규칙 (강제)
  • 켜는 기준은 미확인 합계가 아니라 「미확인 수가 늘어난 방」이다 — 합계(> 0)로 켜면 일부러 안 읽고 둔 방 하나 때문에 페이지를 열 때마다 알림이 굳고, 「합계가 줄면 끈다」면 새 메시지가 온 방은 두고 옛 방만 읽어도 꺼진다. 늘어난 방을 표시해 두고 그 방들의 미확인이 모두 0 이 되면 끈다. 페이지를 열 때 이미 쌓여 있던 미확인은 대상이 아니다(숫자 배지가 알려 준다). 공지는 채팅 메시지가 아니라서 뺀다.
  • 「확인했다」 = 그 방이 실제로 보였다 — isViewingConversation() 은 채팅 화면 + 그 방이 선택됨 + 탭이 보임(!document.hidden) + 네이티브 앱이 포그라운드(hereby:app-state), 위젯은 여기에 패널이 열림까지 요구한다. 네이티브 앱은 WebView 의 visibilitychange 가 기기마다 오지 않을 수 있어 앱 셸(capacitor-init.ts)이 Capacitor appStateChange 를 이벤트로 따로 알린다. 조건이 안 맞으면 새 메시지를 읽음 처리하지 않고 미확인으로 쌓는다. 보던 방이 다시 보이는 순간(탭 복귀 visibilitychange · 패널 다시 열기) markViewedConversationRead() 가 읽음으로 넘긴다. ⚠️ 예전에는 방이 선택돼 있기만 하면 무조건 읽음 처리해서, 패널을 닫았거나 다른 탭에 가 있는 동안 온 답장이 배지에도 알림에도 안 잡히고 상대에게는 「읽음」으로 보였다(2026-09-17 재현).
  • 패널을 열지 않아도 실시간으로 받는다 — 로그인하면 위젯이 대화 목록 구독(subscribeToConversationUpdates)을 건다. 60초 주기 조회만으로는 「메시지가 왔는데 탭이 그대로」인 시간이 길다.
  • 구독은 몇 번을 불러도 한 벌이다 — 패널 첫 열기 · 위젯 감시 시작 · WS 재접속(토큰 갱신마다)이 모두 같은 함수를 부른다. ⚠️ 한 벌 보장을 빼면 메시지 한 건에 미확인이 여러 번 오른다(예전: 직접 처리 + 이벤트 재수신으로 0 → 2, 재접속마다 리스너 누적).
  • 제목은 되돌릴 때 덮지 않는다 — 알림을 끌 때 제목이 아직 알림 문구일 때만 원래 제목으로 돌린다. 그사이 화면이 제목을 직접 바꿨다면(근무표 인쇄 등) 그 값을 둔다. 문구는 로케일 키 messenger.tab_attention_title.
  • 파비콘은 href 만 바꾸지 않고 링크를 통째로 바꾼다 — 켜면 앱의 <link rel="icon"> 을 떼어 두고 관리용 링크(data-tab-attention) 하나를 붙이며, 끄면 떼어 둔 같은 요소를 제자리에 도로 꽂는다. 크기별 링크가 여럿인 문서에서 href 하나만 바꾸면 브라우저가 다른 링크를 골라 알림이 안 보일 수 있다. 파비콘 캐시 버스팅(?v=)은 원래 링크에 그대로 남는다.
  • 파비콘 프레임은 PNG 다(data:image/png) — 사파리는 SVG 파비콘을 탭에 그리지 않는다. 레지스트리 글리프의 경로를 캔버스(Path2D)에 직접 칠해 64px 로 뽑는다(이미지를 불러오지 않아 비동기·캔버스 오염이 없다). 캔버스가 없는 환경에서만 같은 그림의 SVG 로 내려간다. ⚠️ 앱의 원래 파비콘은 아직 SVG 라 사파리에서는 알림이 꺼지면 기본 아이콘으로 돌아간다.
  • 파비콘은 채운 말풍선 아이콘 하나 + 오른쪽 위 작은 빨간 점이고, 말풍선 색만 primary ↔ dark 로 0.5초마다 번갈아 칠한다 — 원은 두르지 않는다(2026-09-17 사용자 결정). 점 둘레는 흰 테두리가 아니라 말풍선을 파낸 투명한 틈으로 뗀다 — 흰 테두리는 어두운 탭 줄에서 흰 고리로 떠 보이고, 틈이 없으면 주황 말풍선 위 빨간 점이 묻힌다. 아이콘은 레지스트리 ic-message-fill 을 그대로 쓰고(16px 탭에서는 선 아이콘보다 면이 또렷하다), 바깥 viewBox 를 글리프 외곽에 맞춰 잘라 탭 칸을 최대한 채운다(BUBBLE_VIEWBOX — 글리프를 바꾸면 다시 잰다).
  • 프레임 색은 :root 토큰을 실행 시점에 읽어 값으로 박는다(--color-primary · --color-neutral-900 · 점 --color-red-500) — 파비콘 SVG 는 문서 밖에서 그려져 var() 가 안 먹는다. 토큰을 못 읽으면 색을 지어내지 않고 제목만 바꾼다.
  • 박자는 워커 타이머에서 받는다 — 알림이 필요한 때는 대개 탭이 뒤에 있을 때인데, 크롬은 숨은 탭의 setInterval 을 1초로, 5분 뒤에는 1분에 한 번으로 줄여 파비콘이 사실상 멈춘다. 워커(Blob URL)를 못 만들 때만 setInterval 로 내려간다. 배포 응답(S3+CloudFront)에는 CSP 헤더가 없어 워커가 막히지 않는다(2026-09-17 확인) — CSP 를 새로 걸 때는 worker-src blob: 을 허용해야 숨은 탭 파비콘이 멈추지 않는다.
  • iframe 안에서는 판정하지 않는다 — 임베드 문서의 제목·파비콘은 탭에 안 나타난다. 부모 문서의 위젯 하나만 판정한다.
  • FAB 은 확인 안 한 새 메시지가 있는 동안 2초마다 통통 튄다(.ds-fab--bounce, 한 주기 = 세 번 점점 낮게 0.8초 + 쉼 1.2초, 무한 반복) — 켜는 기준은 탭 알림과 같다(2026-09-17 사용자 결정). 패널을 한 번 열어 봤으면 새 메시지가 또 올 때까지 튀지 않는다(bounceAcknowledged) — 멈출 방법이 「그 방을 읽기」뿐이면 일부러 안 읽는 방 하나 때문에 세션 내내 튄다(스스로 움직이는 것을 멈출 수단 · 접근성). 탭 제목·파비콘은 이것과 무관하게 읽을 때까지 남는다. 마우스를 올리면 그 자리에 멈추고(움직이는 버튼은 누르기 어렵다), prefers-reduced-motion 이면 튀지 않는다.
  • ⚠️ 튀는 움직임은 transform 으로만 준다 — FAB 의 hover 확대(hover:scale-125)는 Tailwind v4 에서 scale 속성이라 서로 덮지 않는다. translate 속성으로 바꾸면 translate-* 유틸과, right 를 움직이면 가로 슬롯 계약(--ds-fab-dodge)과 부딪힌다.

옆에 서는 두 UI ​

이 세트와 자리를 나눠 쓰지만 규격은 각자의 문서에 있다 — 여기에 다시 적지 말 것.

  • AI 어시스턴트 도크 — 화면 오른쪽에 붙어 본문을 밀어내는 상시 크롬. 폭(--ds-ai-dock-w) · 층(65) · 모달 딤 여백 · 채팅 FAB 비킴 계산이 전부 그 문서에 있다.
  • 불편·오류 신고 — 팝업이 떠 있을 때만 뜨는 딤 위 버튼(65)과 신고 모달(68). 캡처가 무엇을 담고 무엇을 빼는지도 그쪽이다. ⚠️ 캡처에서 빼는 대상을 늘리면 신고 화면이 조용히 비어 간다 — 예전에 .ds-fab-layer 를 통째로 걸러 채팅·어시스턴트 패널과 시트 레일이 그림에서 빠졌다.

두 FAB 는 transition-all 이 걸려 있어 변수가 바뀌면 부드럽게 미끄러진다. 드로어가 닫히면 변수를 지워(또는 클래스를 내려) 제자리로 돌아온다.