@nolit/dsh-notify 0.0.0-stage → 0.0.1-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +445 -0
  2. package/LICENSE +21 -0
  3. package/PROVENANCE.md +96 -0
  4. package/README.md +331 -2
  5. package/cordis.patch.yml +19 -0
  6. package/docs/2026-10-20-dsh-notify-design.md +335 -0
  7. package/docs/2026-10-20-final-status.md +125 -0
  8. package/docs/2026-10-23-asset-policy.md +170 -0
  9. package/docs/2026-10-23-deployment-strategy-review.md +530 -0
  10. package/docs/2026-10-23-headless-nonintrusive-ui-architecture.md +579 -0
  11. package/docs/2026-10-23-hybrid-dual-mode-design.md +955 -0
  12. package/docs/2026-10-23-timezone-global-report.md +204 -0
  13. package/docs/2026-10-23-web-notify-plugin-spec.md +454 -0
  14. package/docs/2026-10-23-web-plugin-mount.md +111 -0
  15. package/docs/2026-10-23-windows-widget-poc-report.md +312 -0
  16. package/docs/2026-10-24-linux-desktop-verification.md +134 -0
  17. package/docs/BOOT-CARD.md +176 -0
  18. package/docs/RECOVERY-NOTES.md +111 -0
  19. package/docs/WORK-PROTOCOL.md +155 -0
  20. package/docs/deepseek-peak-spec.md +370 -0
  21. package/docs/dsh-harness-notify-spec.md +149 -0
  22. package/icon.svg +6 -0
  23. package/lib/client.js +2175 -0
  24. package/lib/client.src.js +1966 -0
  25. package/lib/index.js +13 -0
  26. package/lib/locale-table.js +215 -0
  27. package/locale/en.json +104 -0
  28. package/locale/ko.json +104 -0
  29. package/market/icons/dsh-notify.svg +6 -0
  30. package/market/index.json +77 -0
  31. package/package.json +80 -4
  32. package/scripts/build-client.mjs +41 -0
  33. package/scripts/gen-locale.mjs +36 -0
  34. package/test/client.test.mjs +601 -0
  35. package/test/locale.test.mjs +114 -0
  36. package/test/manifest.test.mjs +115 -0
  37. package/test/peak.test.mjs +370 -0
  38. package/test/typography.test.mjs +85 -0
@@ -0,0 +1,579 @@
1
+ # DSH 알림 웹 플러그인 — 헤드리스 코어 + 비침습 UI 아키텍처
2
+
3
+ <!-- skeleton:
4
+ version: 1.0
5
+ kind: design
6
+ status: reference
7
+ updated: 2026-10-23
8
+ summary: Headless core plus non-intrusive UI architecture for the notification plugin: the split that keeps delivery silent.
9
+ -->
10
+
11
+
12
+ > 상태: proposed · 최종 갱신: 2026-10-23 · git: main @ 8129b54 · 상위: [README.md](../README.md)
13
+
14
+ - 목적: BYO 에셋·라이선스 정책과 UI 비침습 원칙을 하나의 **단독 웹 플러그인 완성 설계**로 통합한다.
15
+ - 범위: DSH 웹 플러그인만. 윈도우 위젯과의 통신은 없다.
16
+ - 이번 단계에서는 **코드 파일을 쓰지 않는다.** 이 보고서만 제출한다.
17
+ - 이전 초안의 `sidebar.footer.action` 고정 배치는 폐기하고, 아래 구조를 기준으로 재작성한다.
18
+
19
+ ## 인덱스
20
+
21
+ | § | 섹션 | 한 줄 요약 |
22
+ |---|---|---|
23
+ | 1 | 최종 결정 | 설정은 `settings.section`, 알림 UI는 `shell.overlay`, 코어는 슬롯 비의존 |
24
+ | 2 | UI 실측 | 뷰포트·사이드바 접힘·푸터 구조·z-index 레이어 |
25
+ | 3 | 간섭 분석 | 푸터/헤더/플로팅/모달의 레이아웃 리스크 |
26
+ | 4 | 진입점 비교 | 공식 설정 섹션이 최적, 푸터·배지는 보조 이하 |
27
+ | 5 | 헤드리스 코어 | 이벤트·필터·권한·오디오·저장·리더를 UI에서 분리 |
28
+ | 6 | UI 어댑터 | 설정 섹션·동의 카드·토스트만 얇게 연결 |
29
+ | 7 | BYO 에셋 정책 | MIT, 번들 없음, Web Audio 합성 + 사용자 오디오 |
30
+ | 8 | 사용자 흐름 | 첫 설치·설정·알림·다중 탭·접힘 상태 |
31
+ | 9 | 구현 계약 | 파일 구조, 코어 API, 상태 스키마 |
32
+ | 10 | 검증 계획 | 뷰포트/z-index/권한/오디오/다중 탭 매트릭스 |
33
+ | 11 | 리스크·결정 | 남은 위험과 확정 필요 항목 |
34
+
35
+ ---
36
+
37
+ ## 1. 최종 결정
38
+
39
+ | 영역 | 결정 |
40
+ |---|---|
41
+ | 코어 | React·슬롯·DOM 렌더링을 모르는 **헤드리스 엔진** 하나 |
42
+ | 설정 진입점 | DSH 공식 **`settings.section`** (Settings 모달 내 "알림" 섹션) |
43
+ | 첫 사용 동의 | **`shell.overlay`**의 일회성 비모달 카드 |
44
+ | 알림 폴백 | **`shell.overlay`**의 일시적 토스트 스택 |
45
+ | 사이드바 | **아무것도 추가하지 않는다.** 푸터 액션·토글 배지 없음 |
46
+ | 헤더 | 사용하지 않는다(세션 스코프·비전역) |
47
+ | 영구 배지 | 사용하지 않는다(콘텐츠 가림·z-index 충돌) |
48
+ | 오디오 | 기본은 Web Audio 합성, 사용자 BYO 오디오는 선택 |
49
+ | 라이선스 | MIT, 폰트/로고/오디오 번들 없음 |
50
+
51
+ **한 문장:** 알림의 두뇌는 슬롯 밖에 두고, 사용자가 설정할 때만 공식 Settings 모달 안에 나타나며, 평소에는 사이드바/헤더를 전혀 건드리지 않는다.
52
+
53
+ ---
54
+
55
+ ## 2. UI 실측
56
+
57
+ ### 2.1 프레임과 뷰포트
58
+
59
+ | 항목 | 실측/계약 | 의미 |
60
+ |---|---|---|
61
+ | 앱 프레임 | 3열 그리드 `sidebar \| center \| rightbar`, `overflow:hidden`, `height:100%` | 전체 뷰포트를 프레임이 소유 |
62
+ | 사이드바 폭 | 264–420px, 기본 280px | 좁은 화면에서도 264px 이상 |
63
+ | 접힘 레일 | 56px | 아이콘만 남는 상태 |
64
+ | 자동 접힘 | 뷰포트 < 1024px | 좁은 창에서 사이드바가 자동으로 레일이 됨 |
65
+ | 우측 패널 | 기본 45%, 최대 70%, 중앙 최소 400px | 우측 패널이 열리면 사이드바가 접힐 수 있음 |
66
+ | 오버레이 레이어 | `position:absolute; inset:0; z-index:20; pointer-events:none` | 프레임 전체를 덮는 비모달 레이어 |
67
+ | 설정 모달 | `z-index:1000`, 800×800 max, nav 188px | 모달이 열리면 오버레이(z20)보다 위 |
68
+ | 메뉴/팝오버 | `z-index:100`, 일부 포털 `1100` | 일반 팝오버가 오버레이보다 위 |
69
+ | 온보딩 | `z-index:900–1001` | 최상위 안내 레이어 |
70
+
71
+ ### 2.2 사이드바 푸터 구조
72
+
73
+ ```
74
+ sidebar
75
+ ├─ topStrip / logoRow
76
+ ├─ newSession
77
+ ├─ panelList
78
+ ├─ regionArea (workspaces/sessions, flex:1, overflow:hidden)
79
+ └─ footArea (flex column, flex:none)
80
+ ├─ footerActions ← sidebar.footer.action (list)
81
+ └─ settingsArea ← sidebar.settings (single, Settings 트리거)
82
+ ```
83
+
84
+ - `sidebar.footer.action`은 **list** 슬롯이다. 여러 플러그인이 동시에 등록할 수 있다.
85
+ - `.footerActions`는 `display:flex` 한 줄이며, `gap`/`wrap`이 없다. 액션이 늘면 폭이 눌리거나 넘칠 수 있다.
86
+ - `wide` prop은 접힘 애니메이션 동안 `true`였다가 정착 후 `false`가 된다. 텍스트가 있는 컴포넌트는 150ms 동안 페이드/언마운트가 발생한다.
87
+ - **Windows Electron**: 접힘 상태에서 `[data-windows-titlebar] .collapsed .footArea { display:none }` → 푸터 액션이 **완전히 사라진다**.
88
+ - **macOS**: 접힘 상태에서 사이드바 컬럼 자체가 숨겨진다 → 푸터 액션 접근 불가.
89
+ - 우측 패널이 열리면 수동 확장 사이드바도 접힌다 → 푸터 액션이 다시 사라질 수 있다.
90
+
91
+ ### 2.3 z-index 레이어
92
+
93
+ | 레이어 | z-index | 성격 |
94
+ |---|---:|---|
95
+ | 프레임 열/콘텐츠 | auto | 사이드바·중앙·우측 |
96
+ | `shell.overlay` | 20 | 프레임 전체, `pointer-events:none`, 자식만 auto |
97
+ | Windows 사이드바 토글 | 30 | 캡션 영역 고정 |
98
+ | Cordis 패널(푸터에서 열리는 fixed 패널) | 30 | 오버레이보다 위, 모달보다 아래 |
99
+ | 메뉴/팝오버 | 100 | 설정 모달 아래 |
100
+ | 설정 모달 | 1000 | 전체 화면 mask + 800px panel |
101
+ | 온보딩 | 900–1001 | 최상위 |
102
+ | 일부 portal 메뉴 | 1100 | 모달 위 |
103
+
104
+ **핵심:** `shell.overlay`(20)는 설정 모달(1000)보다 **항상 아래**다. 오버레이 안에 설정 UI를 넣으면 Settings가 열렸을 때 가려진다. 반대로 설정은 공식 모달 안(`settings.section`)에 넣어야 한다.
105
+
106
+ ---
107
+
108
+ ## 3. 간섭 분석
109
+
110
+ ### 3.1 푸터 액션을 주 진입점으로 쓸 때의 문제
111
+
112
+ | 상태 | 발생 | 영향 |
113
+ |---|---|---|
114
+ | 사이드바 확장 | 정상 | 설정 버튼 위에 한 줄 추가, 세로 공간 소모 |
115
+ | 사이드바 접힘(56px) | `wide=false` | 라벨/상태 텍스트가 사라지고 아이콘만 남음 |
116
+ | Windows Electron 접힘 | `.footArea` display:none | **진입점 자체가 사라짐** |
117
+ | macOS 접힘 | 컬럼 숨김 | **진입점 자체가 사라짐** |
118
+ | 뷰포트 <1024 | 자동 접힘 | 위와 동일 |
119
+ | 우측 패널 열림 | 사이드바 자동 접힘 | 위와 동일 |
120
+ | 다른 플러그인 액션 추가 | footerActions flex row | 폭 경합·줄바꿈 없음·오버플로 |
121
+ | 접힘 애니메이션 | `wide` 150ms 전환 | 텍스트/패널이 깜빡이거나 잘림 |
122
+ | fixed 패널 | `left:12; bottom:56` | 사이드바 폭/본문과 겹침, 모달 뒤로 숨음 |
123
+ | z-index | 패널 30 vs 모달 1000 | 설정 모달이 열리면 패널이 가려짐 |
124
+
125
+ **판정:** 푸터는 "설정 진입점"으로 부적합하다. 특히 접힘 상태에서 사라지는 것은 알림 권한/소리 설정처럼 **한 번은 반드시 접근해야 하는 기능**에 치명적이다.
126
+
127
+ ### 3.2 헤더/플로팅/모달의 문제
128
+
129
+ | 후보 | 문제 |
130
+ |---|---|
131
+ | `conversation.header` | 세션 스코프. 세션이 없으면 사라지고, 전역 알림 설정과 무관 |
132
+ | `sidebar.toggle.badge` | 접힘 상태 전용·비상호작용. 상태 점은 가능하지만 설정 진입점은 아님 |
133
+ | `shell.bottom` | 전체 폭 예약 → 콘텐츠 높이 감소. 알림 설정에는 과함 |
134
+ | `sidebar.panellist` + `main` 패널 | 대화 화면을 점유. 알림 센터로 확장할 때만 정당 |
135
+ | 우측 패널 | 무겁고 세션 문맥에 종속 |
136
+ | 고정 플로팅 배지 | 본문/작성창을 가림, 해제 불가, z-index 충돌, 라이프사이클 없음 |
137
+ | 설정 모달을 직접 띄우는 자체 모달 | 업무를 차단하고 DSH의 기존 설정과 이중화 |
138
+
139
+ ### 3.3 설정 모달 안에서의 제약
140
+
141
+ - 설정 패널은 800px, 좌측 nav 188px → 콘텐츠 폭 약 612px.
142
+ - `settings.section`은 **list** 슬롯이며 `label`, `order`를 받고, 콘텐츠는 `{ close }` prop을 받는다.
143
+ - 모달 내부는 스크롤 컨테이너(`overflow-y:auto`)이므로, 섹션 콘텐츠는 자체 카드/구분선으로 구성하고 과도한 중첩 스크롤을 피한다.
144
+ - 설정 모달 자체가 z1000이므로 그 안의 UI는 오버레이 토스트/배지와 z-index 경쟁을 하지 않는다.
145
+
146
+ ---
147
+
148
+ ## 4. 진입점 비교와 최종 제안
149
+
150
+ | 진입점 | 지속성 | 발견성 | 접힘 영향 | 레이아웃 간섭 | z-index | 판정 |
151
+ |---|---|---|---|---|---|---|
152
+ | `settings.section` | 설정 열 때 | 높음(설정 톱니) | 없음 | 없음(모달 내부) | 1000 내부 | **주 진입점** |
153
+ | `shell.overlay` 동의 카드 | 1회 | 중간(첫 실행) | 없음 | 일시적, 우하단 | 20 | **첫 사용 전용** |
154
+ | `shell.overlay` 토스트 | 이벤트 시 | 낮음(보조) | 없음 | 일시적, 우하단 | 20 | **폴백 전용** |
155
+ | `sidebar.footer.action` | 상시 | 높음 | **사라짐** | 푸터/본문 경합 | 30 | 사용 안 함 |
156
+ | `sidebar.toggle.badge` | 접힘 시 | 낮음 | 접힘 전용 | 작은 점 | 30 | 후속 선택 |
157
+ | `conversation.header` | 세션 시 | 중간 | 세션 없으면 없음 | 헤더 경합 | 1 | 사용 안 함 |
158
+ | `shell.bottom` | 상시 | 높음 | 없음 | 높이 예약 | auto | 사용 안 함 |
159
+ | `sidebar.panellist` + main | 상시 | 높음 | 없음 | 대화 화면 점유 | auto | 알림 센터 이후 |
160
+ | 고정 플로팅 배지 | 상시 | 중간 | 없음 | 본문 가림 | 20–30 | 사용 안 함 |
161
+
162
+ **최종 제안: `settings.section` + `shell.overlay` 2면 구조.**
163
+
164
+ 1. **설정 면**: `settings.section`에 `id: "notifications"`, `order: 20`, `label: "알림"`으로 등록.
165
+ - 권한/소리 상태, 알림 종류 토글, BYO 오디오, 테스트, 데이터 초기화.
166
+ - 사용자는 Settings 톱니 → 알림에서 모든 것을 제어한다.
167
+ 2. **첫 사용 면**: `shell.overlay`에 일회성 동의 카드.
168
+ - `Notification.permission === 'default'`이고 `consent !== 'done'`일 때만.
169
+ - `[알림 켜기]` 클릭에서 `requestPermission()` + `AudioContext.resume()`을 **같은 사용자 제스처**로 실행.
170
+ - `[나중에]`는 `consent='dismissed'`로 저장하고 다시 뜨지 않는다.
171
+ 3. **폴백 면**: `shell.overlay`에 토스트 스택.
172
+ - 권한 거부/미지원, 또는 `suppressWhenFocused`로 OS 알림을 생략했을 때만.
173
+ - 최대 3개, 6초 자동 닫힘, `pointer-events:none` 컨테이너 + 카드만 auto.
174
+ 4. **사이드바/헤더는 건드리지 않는다.**
175
+ - 접힘/확장/Windows/macOS/우측 패널 상태와 무관하게 설정과 알림이 동작한다.
176
+ 5. **후속 선택(필요 시)**: `sidebar.toggle.badge`에 "권한 필요" 점만.
177
+ - 비상호작용·접힘 전용·클릭 없음. 설정 진입점으로 쓰지 않는다.
178
+
179
+ **왜 `settings.section`이 최적인가:** DSH가 이미 제공하는 공식 설정 셸을 그대로 쓰므로 레이아웃을 새로 만들지 않고, 모달 z1000 안에서 다른 UI와 충돌하지 않으며, 접힘 상태와 무관하다. 알림은 "가끔 설정하는 기능"이므로 상시 노출보다 설정 페이지가 UX상 정확하다.
180
+
181
+ ---
182
+
183
+ ## 5. 헤드리스 코어
184
+
185
+ ### 5.1 책임 경계
186
+
187
+ | 계층 | 책임 | 금지 |
188
+ |---|---|---|
189
+ | Core | 이벤트 수신, 루트 필터, 쿨다운, 권한 상태, 오디오 로더, 설정/동의 저장, 탭 리더, 토스트 큐, 알림 발행 | React, 슬롯 이름, DOM 렌더링, CSS |
190
+ | UI adapter | 상태 구독, 버튼/폼 렌더링, 사용자 제스처 전달 | 이벤트 필터/쿨다운/저장 로직 |
191
+ | Host entry | Loader 등록용 no-op | 모든 런타임 동작 |
192
+
193
+ ### 5.2 코어 API(개념)
194
+
195
+ ```js
196
+ const core = createNotifyCore({
197
+ config, // cordis.patch.yml 기본값
198
+ env: {
199
+ now, storage, notification, audio, locks, visibility, focus,
200
+ },
201
+ sources: {
202
+ status(handler), // api-session/status
203
+ error(handler), // api-session/error
204
+ approval(handler), // approval/request
205
+ question(handler), // user-questions/request
206
+ session(id), // ctx.sessions.list.getSnapshot().byId[id]
207
+ scopeOf(scope), // ctx.sessions.scopeOf(scope)
208
+ },
209
+ })
210
+
211
+ core.start()
212
+ core.stop()
213
+
214
+ core.getState() // { settings, permission, audio, leader, consent, toasts, ... }
215
+ core.subscribe(listener) // UI 어댑터용
216
+
217
+ core.actions.requestNotifications()
218
+ core.actions.enableSound()
219
+ core.actions.test()
220
+ core.actions.setSetting(key, value)
221
+ core.actions.setByoAudio(file)
222
+ core.actions.clearByoAudio()
223
+ core.actions.dismissConsent()
224
+ core.actions.dismissToast(id)
225
+ ```
226
+
227
+ - `sources`를 주입해 Cordis/DOM 없이도 Node 테스트가 가능하다.
228
+ - UI 어댑터는 `core.getState()`/`subscribe`만 사용한다.
229
+ - 코어는 `settings.section`/`shell.overlay` 같은 문자열을 **모른다.**
230
+
231
+ ### 5.3 상태 스키마
232
+
233
+ ```js
234
+ {
235
+ ready: true,
236
+ settings: {
237
+ notifyOnComplete: true,
238
+ notifyOnApproval: true,
239
+ notifyOnQuestion: true,
240
+ notifyOnError: true,
241
+ completeCooldownMs: 5000,
242
+ soundEnabled: true,
243
+ volume: 0.3,
244
+ soundSource: 'synth' | 'custom',
245
+ suppressWhenFocused: false,
246
+ requireInteractionForApproval: true,
247
+ notifyOnSubagents: false,
248
+ },
249
+ permission: 'default' | 'granted' | 'denied' | 'unsupported',
250
+ audio: 'locked' | 'unlocked' | 'unsupported',
251
+ leader: true,
252
+ consent: 'pending' | 'done' | 'dismissed',
253
+ toasts: [{ id, kind, title, body }],
254
+ byoAudio: { name, size, type } | null,
255
+ lastEventAt: 0,
256
+ }
257
+ ```
258
+
259
+ ### 5.4 저장 전략
260
+
261
+ | 데이터 | 저장소 | 이유 |
262
+ |---|---|---|
263
+ | 설정 | `localStorage` `dsh-notify-web:settings` | 작은 JSON, 동기 접근, 탭 간 공유 |
264
+ | 동의 상태 | `localStorage` `dsh-notify-web:consent` | 1회성 플래그 |
265
+ | BYO 오디오 메타 | `localStorage` `dsh-notify-web:audio` | 이름/크기/타입/사용 여부 |
266
+ | BYO 오디오 바이트 | **IndexedDB** `dsh-notify-web/audio` | localStorage 용량 한계 회피, 바이너리 저장 |
267
+ | 오디오 캐시 | 메모리 `AudioBuffer` | 재생 지연 최소화 |
268
+
269
+ - localStorage는 문자열/용량 제한이 있으므로 오디오 바이트를 넣지 않는다.
270
+ - IndexedDB를 쓸 수 없으면 BYO 오디오는 **세션 한정**으로 동작하고, 설정 페이지에 "재시작 후 다시 선택" 안내.
271
+ - 저장 스키마에는 `schemaVersion`을 두고 마이그레이션한다.
272
+
273
+ ### 5.5 오디오 로더
274
+
275
+ | 소스 | 동작 |
276
+ |---|---|
277
+ | `synth` (기본) | Web Audio oscillator 2음 차임. 에셋 없음 |
278
+ | `custom` (BYO) | 사용자가 파일 선택 → `decodeAudioData` → `AudioBuffer` 재생 |
279
+ | 실패 | synth로 자동 폴백, 설정 페이지에 짧은 안내 |
280
+ | 잠금 | `AudioContext`는 오직 사용자 제스처에서 생성/`resume` |
281
+ | 볼륨 | 0–0.5, gain envelope로 클리핑 방지 |
282
+
283
+ **BYO 오디오 라이선스:** 사용자가 선택한 파일의 권리는 사용자에게 있다. 플러그인은 파일을 번들/전송/호스팅하지 않는다.
284
+
285
+ ### 5.6 탭 리더와 동기화
286
+
287
+ - Web Locks `dsh-notify-web:leader`로 한 탭만 알림 발행.
288
+ - Web Locks가 없으면 localStorage 리스(10초 TTL, 3초 갱신).
289
+ - 설정 변경은 `storage` 이벤트로 다른 탭에 전파.
290
+ - 알림 `tag`는 `dsh-notify:<sessionId>:<kind>`로 OS 수준 병합.
291
+ - 승인/질문은 `callId`/request id 기반 30초 dedupe.
292
+
293
+ ### 5.7 이벤트 필터
294
+
295
+ | 이벤트 | 규칙 |
296
+ |---|---|
297
+ | `api-session/status` false | 루트 세션만, 5초 쿨다운, `running=true`에서 쿨다운 리셋 |
298
+ | `api-session/error` | 루트 세션만, 메시지 160자 |
299
+ | `approval/request` | 항상, 알림 후 즉시 `next()` |
300
+ | `user-questions/request` | 항상, 알림 후 즉시 `next()` |
301
+
302
+ - `agent/turn-stopping` 금지(serial).
303
+ - `agent/error`/`agent/status`는 브라우저 포워딩 대상이 아니므로 사용하지 않는다.
304
+ - 승인/질문 핸들러에서 `await`하지 않는다.
305
+
306
+ ---
307
+
308
+ ## 6. UI 어댑터
309
+
310
+ ### 6.1 `settings.section` — 주 진입점
311
+
312
+ ```
313
+ registration:
314
+ name: 'settings.section'
315
+ id: 'notifications'
316
+ order: 20
317
+ label: '알림'
318
+ component: NotificationsSettings
319
+ ```
320
+
321
+ 콘텐츠 구성:
322
+
323
+ 1. **상태 카드**
324
+ - 브라우저 알림 권한, 오디오 상태, 이 탭의 리더 여부, 현재 origin.
325
+ - "DSH 탭이 열려 있을 때만 알림이 동작합니다" 명시.
326
+ 2. **권한/소리**
327
+ - `브라우저 알림 켜기` → `actions.requestNotifications()`.
328
+ - `소리 켜기` → `actions.enableSound()`.
329
+ - `테스트 알림`.
330
+ 3. **소리 소스**
331
+ - `기본 합성음` / `사용자 오디오` 라디오.
332
+ - `오디오 파일 선택` (`accept="audio/*"`), 파일명/크기 표시, `초기화`.
333
+ - 라이선스 책임 안내.
334
+ 4. **필터**
335
+ - 완료/승인/질문/오류, 서브에이전트 완료, 포커스 시 완료 숨김, 쿨다운.
336
+ 5. **데이터**
337
+ - 설정 초기화, 동의 상태 초기화, BYO 오디오 삭제.
338
+ 6. **정보**
339
+ - 라이선스(MIT), "번들 에셋 없음", 버전.
340
+
341
+ **금지:** 모달 밖으로 튀는 `position:fixed` 패널, 자체 전체 화면 mask, 설정 모달과 별개의 자체 모달.
342
+
343
+ ### 6.2 `shell.overlay` — 동의 카드 + 토스트
344
+
345
+ - 오버레이 레이어는 `pointer-events:none`; 카드만 `pointer-events:auto`.
346
+ - 동의 카드:
347
+ - `role="dialog"`, `aria-modal="false"`, 제목/설명/2버튼.
348
+ - 우하단 `right:16; bottom:16; width:min(360px, calc(100vw - 32px))`.
349
+ - `Esc` 닫기, `나중에`와 동일하게 동의 보류.
350
+ - 설정 모달(z1000)이 열려 있으면 카드가 뒤로 가려질 수 있음 → 카드가 보이지 않아도 설정 페이지에서 동일 기능 제공.
351
+ - 토스트:
352
+ - 최대 3개, 6초, 같은 `tag` 교체.
353
+ - `aria-live="polite"`.
354
+ - `z-index`는 오버레이 레이어(20) 안에서만. 모달을 이기려 하지 않는다.
355
+ - 접근성: 포커스 트랩 없음(비모달), 키보드 닫기, 색 대비, `prefers-reduced-motion`.
356
+
357
+ ### 6.3 사용하지 않는 어댑터
358
+
359
+ | 어댑터 | 이유 |
360
+ |---|---|
361
+ | `sidebar.footer.action` | 접힘/Windows/macOS에서 사라짐, 폭 경합, 설정 진입점으로 부적합 |
362
+ | `sidebar.toggle.badge` | 비상호작용·접힘 전용. 상태 점이 필요할 때만 후속 |
363
+ | `conversation.header` | 세션 스코프, 전역 설정에 부적합 |
364
+ | `shell.bottom` | 높이 예약, 레이아웃 침습 |
365
+ | `sidebar.panellist`/`main` | 대화 화면 점유. 알림 센터로 확장할 때만 |
366
+ | 고정 플로팅 배지 | 콘텐츠 가림, 해제 불가, z-index 충돌 |
367
+
368
+ ---
369
+
370
+ ## 7. BYO 에셋·라이선스 정책
371
+
372
+ ### 7.1 기본 원칙
373
+
374
+ - 코드: **MIT** ([LICENSE](../LICENSE)).
375
+ - 패키지에 **폰트, DeepSeek 로고, 오디오 파일을 넣지 않는다.**
376
+ - 기본 아이콘은 자체 제작 중립 시계 SVG(MIT).
377
+ - 알림에는 아이콘을 지정하지 않는다(브라우저 기본).
378
+ - 기본 소리는 Web Audio 합성.
379
+ - 사용자 BYO 오디오만 선택적으로 허용하며, 라이선스 책임은 사용자에게 있다.
380
+
381
+ ### 7.2 BYO 오디오 흐름
382
+
383
+ 1. 설정 → 소리 소스 → `사용자 오디오` → 파일 선택.
384
+ 2. 코어가 `decodeAudioData`로 디코드, IndexedDB에 저장.
385
+ 3. 재생 시 `AudioBuffer` 사용. 실패하면 합성음으로 폴백.
386
+ 4. `초기화`로 IndexedDB/localStorage 메타 삭제.
387
+ 5. 파일은 외부로 전송되지 않는다.
388
+
389
+ ### 7.3 면책
390
+
391
+ - 사용자가 주입한 폰트/아이콘/오디오의 저작권·상표권·라이선스 준수 책임은 사용자에게 있다.
392
+ - 프로젝트는 사용자 에셋을 배포/호스팅하지 않는다.
393
+ - 소프트웨어는 AS IS로 제공된다.
394
+ - 상세: [에셋·상표 라이선스 정책](2026-10-23-asset-policy.md).
395
+
396
+ ---
397
+
398
+ ## 8. 사용자 흐름
399
+
400
+ ### 8.1 첫 설치
401
+
402
+ ```
403
+ DSH 페이지 로드
404
+ → 코어 시작 (알림 수신은 즉시)
405
+ → 권한 default + consent pending이면 오버레이 동의 카드
406
+ [알림 켜기] → requestPermission + AudioContext.resume (같은 제스처)
407
+ [나중에] → consent dismissed
408
+ → 권한 granted면 이후 이벤트부터 OS 알림
409
+ ```
410
+
411
+ - 자동 권한 프롬프트 없음.
412
+ - 동의 카드는 1회만. 이후에는 Settings > 알림에서 변경.
413
+ - 사이드바/헤더/레이아웃 변화 없음.
414
+
415
+ ### 8.2 설정 변경
416
+
417
+ ```
418
+ Settings 톱니 → 알림 섹션
419
+ → 권한/소리/필터/BYO 오디오/테스트
420
+ → 변경 즉시 localStorage 저장 + storage 이벤트로 다른 탭 전파
421
+ ```
422
+
423
+ ### 8.3 이벤트 발생
424
+
425
+ ```
426
+ api-session/status(false) / approval / question / error
427
+ → 코어 필터/쿨다운/리더 확인
428
+ → 권한 granted: OS Notification + (설정 시) 소리
429
+ → 권한 denied/unsupported: 오버레이 토스트 + (설정 시) 소리
430
+ ```
431
+
432
+ - 승인/질문은 알림 후 즉시 `next()`.
433
+ - DSH 탭이 닫히면 알림 없음(설계상 한계).
434
+
435
+ ### 8.4 접힘/좁은 화면/우측 패널
436
+
437
+ - 사이드바가 56px로 접히거나 사라져도 알림/설정은 그대로.
438
+ - Settings 톱니는 사이드바 하단에 고정되어 접힘 상태에서도 접근 가능(DSH 소유).
439
+ - 우측 패널 fullscreen이어도 Settings 모달은 z1000으로 접근 가능.
440
+ - 오버레이 토스트는 우측 패널 위에 잠시 표시될 수 있으나 비모달·자동 소멸.
441
+
442
+ ### 8.5 다중 탭
443
+
444
+ - 리더 탭만 OS 알림/소리.
445
+ - 설정 변경은 모든 탭에 전파.
446
+ - 한 탭이 닫히면 다른 탭이 리더 승계.
447
+
448
+ ---
449
+
450
+ ## 9. 구현 계약
451
+
452
+ ### 9.1 파일 구조(권장)
453
+
454
+ ```
455
+ plugin/
456
+ package.json
457
+ cordis.patch.yml
458
+ icon.svg # 중립 시계(MIT)
459
+ locale/{en,ko}.json
460
+ market/index.json
461
+ lib/
462
+ index.js # no-op host
463
+ client.js # built bundle (DSH가 서빙)
464
+ src/
465
+ core.js # 헤드리스 엔진 (React/슬롯 없음)
466
+ ui/
467
+ settings-section.js
468
+ consent-card.js
469
+ toast-host.js
470
+ client.js # 코어 + 어댑터 조립
471
+ test/
472
+ core.test.mjs
473
+ client.test.mjs
474
+ ```
475
+
476
+ - `lib/client.js`는 빌드 산출물이다. v1은 단일 파일에 코어 팩토리를 인라인해 빌드 없이 시작할 수 있지만, **코어는 React/슬롯을 import하지 않는다**는 규칙을 지킨다.
477
+ - 빌드 도구를 쓰면 `src/core.js`를 그대로 테스트하고 `lib/client.js`로 번들한다.
478
+ - `lib/index.js`는 no-op host만.
479
+
480
+ ### 9.2 코어-UI 결합 금지 규칙
481
+
482
+ - 코어 파일에서 `require('react')`, `ctx.slots`, `settings.section`, `shell.overlay` 문자열 금지.
483
+ - UI 어댑터에서 `localStorage`, `Notification`, `AudioContext` 직접 접근 금지. 모두 코어 actions/state 경유.
484
+ - UI는 `core.getState()`/`subscribe`만 읽는다.
485
+ - 코어는 `sources` 주입으로 테스트한다.
486
+
487
+ ### 9.3 패키지 메타
488
+
489
+ - `name: @nolit/dsh-notify`
490
+ - `license: MIT`
491
+ - `dsh.client.platform: 'web'`
492
+ - `dsh.client.immediately: true` (초기 이벤트 누락 최소화)
493
+ - `engines.dsh: '>=0.2.1-rc.2 <0.3.0'`
494
+ - `files`: `lib`, `src`(선택), `cordis.patch.yml`, `locale`, `market`, `icon.svg`, `README`, `LICENSE`
495
+ - `private` 없음(공개 배포 시)
496
+
497
+ ### 9.4 설치
498
+
499
+ ```powershell
500
+ dsh plugin --profile web add link:C:\path\to\DSH Notify\plugin
501
+ ```
502
+
503
+ web 프로필의 `dsh.profile.bundles`에 `@nolit/dsh-notify` 추가. DSH Plugins 페이지에서 활성화.
504
+
505
+ ---
506
+
507
+ ## 10. 검증 계획
508
+
509
+ ### 10.1 레이아웃·z-index 매트릭스
510
+
511
+ | # | 상태 | 기대 |
512
+ |---|---|---|
513
+ | V1 | 1440px, 사이드바 확장 | 설정 섹션 정상, 사이드바 변화 없음 |
514
+ | V2 | 1024px 경계 | 자동 접힘과 무관하게 알림 동작 |
515
+ | V3 | 768px, 사이드바 레일 | 설정은 Settings 모달에서 정상 |
516
+ | V4 | 480px | 동의 카드/토스트가 뷰포트 밖으로 나가지 않음 |
517
+ | V5 | Windows Electron 접힘 | 푸터 없음, 알림/설정 정상 |
518
+ | V6 | macOS 접힘(컬럼 숨김) | 알림/설정 정상 |
519
+ | V7 | 우측 패널 열림/전체화면 | 오버레이 토스트가 프레임 안, 설정 모달 정상 |
520
+ | V8 | Settings 모달 열림 | 설정 섹션 스크롤/포커스 정상, 토스트는 모달 뒤 |
521
+ | V9 | 다크/라이트 | 대비/토큰 정상 |
522
+ | V10 | 다중 탭 | 리더 1개, 설정 동기화 |
523
+ | V11 | 권한 granted/denied/default | Notification/토스트 분기 |
524
+ | V12 | 오디오 잠금/언락/BYO 실패 | 합성음 폴백, 무예외 |
525
+
526
+ ### 10.2 회귀
527
+
528
+ - `docs-contract` 통과.
529
+ - 기존 앱 테스트 19/19는 앱 코드 무변경이므로 영향 없음.
530
+ - 웹 플러그인 단위 테스트: 이벤트 필터/쿨다운/승인 `next()`/권한 폴백/리더/설정 저장/BYO 오디오 폴백.
531
+
532
+ ### 10.3 금지 패턴 검사
533
+
534
+ - `sidebar.footer.action` 문자열이 최종 `client.js`에 없어야 한다.
535
+ - `position:fixed` 설정 패널이 `settings.section` 밖에 없어야 한다.
536
+ - `fetch`/`XMLHttpRequest`/`WebSocket`/`EventSource` 신규 사용 없음.
537
+ - `localStorage` 접근은 코어에만.
538
+ - `agent/turn-stopping` 구독 없음.
539
+
540
+ ---
541
+
542
+ ## 11. 리스크·결정
543
+
544
+ | 리스크 | 영향 | 완화 |
545
+ |---|---|---|
546
+ | Settings 섹션 발견성 | 사용자가 설정 위치를 모를 수 있음 | 첫 실행 동의 카드에 "Settings > 알림" 안내, 테스트 버튼 |
547
+ | 동의 카드가 설정 모달 뒤로 숨음 | 첫 실행 직후 Settings를 열면 카드 안 보임 | 설정 페이지에 동일 버튼 제공, 카드 1회성 |
548
+ | 오버레이 토스트가 모달 뒤로 숨음 | 권한 거부 + Settings 열림 시 토스트 미표시 | OS 알림이 primary, 토스트는 보조. 모달 중에는 조용히 무시 |
549
+ | BYO 오디오 저장 실패 | IndexedDB 불가/쿼터 | 세션 한정 재생, 합성음 폴백, 안내 |
550
+ | 브라우저 자동재생 정책 | 첫 소리 무음 | 사용자 제스처에서만 AudioContext, 상태 표시 |
551
+ | 알림 권한 출처 종속 | 포트 변경 시 재승인 | 설정 페이지에 현재 origin/권한 표시 |
552
+ | DSH API 변경 | 슬롯/이벤트 깨짐 | `engines.dsh` 고정, 이벤트 4종 테스트, 플러그인 내부 격리 |
553
+ | 다중 탭 리더 경합 | 일시 중복/누락 | Web Locks + 폴백 리스 + `tag` |
554
+ | 사용자 에셋 라이선스 | 법적 분쟁 | 번들 없음 + 면책 + MIT |
555
+
556
+ ### 확정 필요
557
+
558
+ | # | 결정 | 권장 |
559
+ |---|---|---|
560
+ | 1 | 주 설정 진입점 | `settings.section` |
561
+ | 2 | 첫 사용 동의 | `shell.overlay` 일회성 카드 |
562
+ | 3 | 사이드바 푸터 | 사용 안 함 |
563
+ | 4 | BYO 오디오 저장 | IndexedDB(바이트) + localStorage(메타) |
564
+ | 5 | 기본 소리 | Web Audio 합성 |
565
+ | 6 | 상태 배지 | v1 없음, 필요 시 `sidebar.toggle.badge` 점만 |
566
+ | 7 | v1 범위 | 설정 섹션 + 동의 카드 + 토스트 + 코어 |
567
+
568
+ ---
569
+
570
+ ## 부록: 이전 초안에서 폐기할 것
571
+
572
+ - `sidebar.footer.action`에 배지/패널을 붙이는 방식.
573
+ - 푸터 버튼에서 `position:fixed` 패널을 여는 방식.
574
+ - UI 컴포넌트 안에 `localStorage`/`Notification`/`AudioContext` 로직을 두는 방식.
575
+ - 설정 모달 밖에 별도 설정 모달을 띄우는 방식.
576
+ - 사이드바 접힘 상태를 고려하지 않은 고정 좌표(`left:12; bottom:56`).
577
+ - `wide` prop 텍스트에 의존하는 상태 표시.
578
+
579
+ 이 보고서를 기준으로 다음 단계에서 코어 → 설정 섹션 → 동의 카드/토스트 순서로 구현한다.