UI/UX D3: dark mode toggle, chat export, accessibility, onboarding modal

- app.py: _JS with dark mode toggle (localStorage + system preference) (D3-17)
- app.py: dark mode CSS overrides for custom chat bubble colors (D3-17)
- app.py: export_chat() -> gr.File .md download button in controls row (D3-18)
- app.py: JS aria-label/role/aria-live injection for chatbot and inputs (D3-19)
- app.py: :focus-visible CSS 3px blue outline for keyboard navigation (D3-19)
- app.py: first-visit onboarding modal with localStorage guard (D3-20)
- app.py: js=_JS wired into gr.Blocks()
- ROADMAP.md: mark all D3 items complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:45:49 +09:00
parent fb438864b1
commit ab437d5d2e
2 changed files with 114 additions and 6 deletions
+4 -3
View File
@@ -347,9 +347,10 @@ youlbot-webui/
- [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS - [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS
#### D3 #### D3
- [ ] 다크 모드 토글 - [x] 다크 모드 토글 — 헤더 🌙/☀️ 버튼 + `localStorage` 기억 + 시스템 설정 자동 감지
- [ ] 채팅 내보내기 버튼 - [x] 채팅 내보내기 — `export_chat` 함수, `.md` 파일 다운로드, `gr.File` 출력
- [ ] 접근성 개선 - [x] 접근성 개선 — `aria-label`/`role`/`aria-live` JS 주입, `:focus-visible` 파란 테두리
- [x] 온보딩 투어 — 첫 방문 모달 (`localStorage` 체크, "시작하기" 버튼으로 닫기)
--- ---
+110 -3
View File
@@ -164,6 +164,24 @@ async def reset_chat(user_id):
return [], [] return [], []
async def export_chat(history):
if not history:
return gr.update(visible=False)
from datetime import datetime
import tempfile
lines = [f"# 율봇 대화 내보내기\n_내보낸 시각: {datetime.now().strftime('%Y-%m-%d %H:%M')}_\n\n"]
for msg in history:
role = "👤 사용자" if msg["role"] == "user" else "🤖 율봇"
content = str(msg.get("content") or "")
lines.append(f"### {role}\n\n{content}\n\n---\n\n")
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".md", delete=False, encoding="utf-8", prefix="youlbot_chat_"
)
tmp.write("".join(lines))
tmp.close()
return gr.update(value=tmp.name, visible=True)
# ── 문서 관리 ───────────────────────────────────────────────────── # ── 문서 관리 ─────────────────────────────────────────────────────
async def ingest_files(files): async def ingest_files(files):
@@ -272,6 +290,69 @@ def _sources_html(sources: list) -> str:
) )
_JS = """
() => {
const htmlEl = document.documentElement;
// D3-17: 다크 모드 토글
window.toggleDarkMode = function() {
htmlEl.classList.toggle('dark');
const isDark = htmlEl.classList.contains('dark');
localStorage.setItem('youlbot_theme', isDark ? 'dark' : 'light');
const btn = document.getElementById('dark-mode-btn');
if (btn) btn.textContent = isDark ? '☀️' : '🌙';
};
const saved = localStorage.getItem('youlbot_theme');
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
htmlEl.classList.add('dark');
}
// D3-19: aria 레이블 설정
function addAriaLabels() {
const ta = document.querySelector('textarea[placeholder*="질문"]');
if (ta) ta.setAttribute('aria-label', '질문 입력창 (Enter 키로 전송)');
document.querySelectorAll('.send-btn').forEach(b => b.setAttribute('aria-label', '메시지 전송'));
const cb = document.getElementById('main-chatbot');
if (cb) { cb.setAttribute('role', 'log'); cb.setAttribute('aria-live', 'polite'); cb.setAttribute('aria-label', '대화 내용'); }
}
// D3-20: 첫 방문 온보딩 모달
function showOnboarding() {
if (localStorage.getItem('youlbot_onboarded')) return;
const modal = document.createElement('div');
modal.id = 'youlbot-onboarding';
modal.innerHTML = `
<div style="position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;">
<div style="background:#fff;border-radius:20px;padding:36px 32px;max-width:400px;width:100%;box-shadow:0 24px 64px rgba(0,0,0,.25);">
<div style="font-size:2.4rem;text-align:center;margin-bottom:12px;">🤖</div>
<h2 style="margin:0 0 6px;font-size:1.3rem;font-weight:700;text-align:center;color:#1e293b;">율봇에 오신 것을 환영합니다!</h2>
<p style="margin:0 0 20px;text-align:center;color:#64748b;font-size:.875rem;">육아·금융 전문 AI 상담 도우미</p>
<ul style="list-style:none;padding:0;margin:0 0 24px;display:flex;flex-direction:column;gap:10px;">
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>💬</span> 아래 입력창에 질문을 입력하세요</li>
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>💡</span> 예시 질문 클릭으로 빠르게 시작</li>
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>📄</span> 문서 등록 탭에서 RAG 문서 추가</li>
<li style="display:flex;align-items:center;gap:10px;font-size:.9rem;color:#374151;"><span>🎤</span> 음성으로도 질문 가능</li>
</ul>
<button onclick="window.closeOnboarding()" style="width:100%;padding:13px;background:#3b82f6;color:#fff;border:none;border-radius:10px;font-size:.95rem;font-weight:600;cursor:pointer;" onmouseover="this.style.background='#2563eb'" onmouseout="this.style.background='#3b82f6'">시작하기</button>
</div>
</div>`;
document.body.appendChild(modal);
}
window.closeOnboarding = function() {
const m = document.getElementById('youlbot-onboarding');
if (m) m.remove();
localStorage.setItem('youlbot_onboarded', '1');
};
setTimeout(function() {
addAriaLabels();
showOnboarding();
const btn = document.getElementById('dark-mode-btn');
if (btn && htmlEl.classList.contains('dark')) btn.textContent = '☀️';
}, 1500);
}
"""
_CUSTOM_CSS = """ _CUSTOM_CSS = """
footer { display: none !important; } footer { display: none !important; }
@@ -314,6 +395,18 @@ footer { display: none !important; }
.app-header { flex-wrap: wrap; gap: 8px; } .app-header { flex-wrap: wrap; gap: 8px; }
.message-wrap .user, .message-wrap .bot { max-width: 92% !important; } .message-wrap .user, .message-wrap .bot { max-width: 92% !important; }
} }
/* 접근성: 포커스 표시 강화 (D3-19) */
:focus-visible {
outline: 3px solid #3b82f6 !important;
outline-offset: 2px !important;
border-radius: 4px;
}
/* 다크 모드 커스텀 요소 오버라이드 (D3-17) */
.dark .message-wrap .user { background: #1e3a5f !important; border-color: #2563eb !important; }
.dark .message-wrap .bot { background: #1e293b !important; border-color: #334155 !important; }
.dark .app-header { border-color: #334155 !important; }
""" """
_THEME = gr.themes.Soft( _THEME = gr.themes.Soft(
@@ -322,7 +415,7 @@ _THEME = gr.themes.Soft(
neutral_hue="slate", neutral_hue="slate",
) )
with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo: with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME, js=_JS) as demo:
with gr.Row(elem_classes=["app-header"]): with gr.Row(elem_classes=["app-header"]):
gr.HTML(""" gr.HTML("""
<div style="display:flex;align-items:center;gap:14px;padding:4px 0;"> <div style="display:flex;align-items:center;gap:14px;padding:4px 0;">
@@ -333,6 +426,15 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo:
</div> </div>
</div> </div>
""") """)
gr.HTML("""
<div style="display:flex;justify-content:flex-end;align-items:center;height:100%;">
<button id="dark-mode-btn" onclick="toggleDarkMode()" title="다크/라이트 모드 전환"
style="background:none;border:1.5px solid #e2e8f0;border-radius:8px;cursor:pointer;
font-size:1.2rem;padding:6px 10px;line-height:1;color:#64748b;transition:all .15s;"
onmouseover="this.style.borderColor='#94a3b8'"
onmouseout="this.style.borderColor='#e2e8f0'">🌙</button>
</div>
""", scale=0, min_width=60)
user_selector = gr.Dropdown( user_selector = gr.Dropdown(
choices=USER_LABELS, choices=USER_LABELS,
value=DEFAULT_USER, value=DEFAULT_USER,
@@ -390,8 +492,12 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo:
with gr.Row(): with gr.Row():
show_thinking = gr.Checkbox(label="사고 과정 표시", value=True) show_thinking = gr.Checkbox(label="사고 과정 표시", value=True)
use_tts = gr.Checkbox(label="음성으로 답변 읽기 (TTS)", value=False) use_tts = gr.Checkbox(label="음성으로 답변 읽기 (TTS)", value=False)
with gr.Column(scale=1, min_width=120): with gr.Column(scale=2, min_width=240):
reset_btn = gr.Button("대화 초기화", size="sm") with gr.Row():
export_btn = gr.Button("💾 내보내기", size="sm", min_width=100)
reset_btn = gr.Button("대화 초기화", size="sm", min_width=100)
export_file = gr.File(label="내보내기 파일", visible=False)
tts_output = gr.Audio(label="음성 답변", autoplay=True, visible=False) tts_output = gr.Audio(label="음성 답변", autoplay=True, visible=False)
use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output]) use_tts.change(lambda v: gr.Audio(visible=v), inputs=[use_tts], outputs=[tts_output])
@@ -412,6 +518,7 @@ with gr.Blocks(title="율봇", css=_CUSTOM_CSS, theme=_THEME) as demo:
send_btn.click(respond, inputs=_respond_inputs, outputs=_respond_outputs) send_btn.click(respond, inputs=_respond_inputs, outputs=_respond_outputs)
msg_box.submit(respond, inputs=_respond_inputs, outputs=_respond_outputs) msg_box.submit(respond, inputs=_respond_inputs, outputs=_respond_outputs)
reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state]) reset_btn.click(reset_chat, inputs=[user_state], outputs=[chatbot, run_ids_state])
export_btn.click(export_chat, inputs=[chatbot], outputs=[export_file])
chatbot.like( chatbot.like(
handle_feedback, handle_feedback,