feat(coach): AI 코칭 버튼 및 버블 팝업 대화형 조언 기능 구현
This commit is contained in:
parent
8a34dd2afc
commit
70d7cb34a3
36 changed files with 4521 additions and 1264 deletions
|
|
@ -130,6 +130,7 @@ class TurnRequest(BaseModel):
|
||||||
|
|
||||||
class LiveCoachRequest(BaseModel):
|
class LiveCoachRequest(BaseModel):
|
||||||
learner_text: str = Field(..., min_length=1)
|
learner_text: str = Field(..., min_length=1)
|
||||||
|
question: Optional[str] = None
|
||||||
client_reply: Optional[str] = None
|
client_reply: Optional[str] = None
|
||||||
turn_seq: Optional[int] = Field(default=None, ge=1)
|
turn_seq: Optional[int] = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
@ -1673,6 +1674,7 @@ async def live_coach_turn(
|
||||||
persona_code=sess.persona_code,
|
persona_code=sess.persona_code,
|
||||||
persona_name=sess.persona.display_name,
|
persona_name=sess.persona.display_name,
|
||||||
learner_text=body.learner_text,
|
learner_text=body.learner_text,
|
||||||
|
question=body.question,
|
||||||
client_reply=body.client_reply,
|
client_reply=body.client_reply,
|
||||||
recent_turns=sess.recent_turns(k=8, visible_to=LEARNER_VISIBLE_AI_ROLE),
|
recent_turns=sess.recent_turns(k=8, visible_to=LEARNER_VISIBLE_AI_ROLE),
|
||||||
evaluation=_latest_turn_evaluation(sess, body.turn_seq),
|
evaluation=_latest_turn_evaluation(sess, body.turn_seq),
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ class LiveCoachInput(BaseModel):
|
||||||
persona_code: str
|
persona_code: str
|
||||||
persona_name: str
|
persona_name: str
|
||||||
learner_text: str
|
learner_text: str
|
||||||
|
question: Optional[str] = None
|
||||||
client_reply: Optional[str] = None
|
client_reply: Optional[str] = None
|
||||||
recent_turns: list[dict[str, str]] = Field(default_factory=list)
|
recent_turns: list[dict[str, str]] = Field(default_factory=list)
|
||||||
evaluation: Optional[dict[str, Any]] = None
|
evaluation: Optional[dict[str, Any]] = None
|
||||||
|
|
@ -546,12 +547,33 @@ def _fallback_suggestion(
|
||||||
)
|
)
|
||||||
|
|
||||||
crisis = guardrail.classify_crisis(text)
|
crisis = guardrail.classify_crisis(text)
|
||||||
|
q = (item.question or "").strip()
|
||||||
if crisis.kind != guardrail.CrisisKind.NONE:
|
if crisis.kind != guardrail.CrisisKind.NONE:
|
||||||
tone = "warn"
|
tone = "warn"
|
||||||
focus = "risk"
|
focus = "risk"
|
||||||
title = "안전 먼저"
|
title = "안전 먼저"
|
||||||
message = "위험 단서가 나온 턴이다. 방법을 캐묻지 말고 최근성, 강도, 보호요인을 차분히 확인해라."
|
message = "위험 단서가 나온 턴이다. 방법을 캐묻지 말고 최근성, 강도, 보호요인을 차분히 확인해라."
|
||||||
next_line = "그 생각이 최근에 얼마나 자주, 얼마나 강하게 올라오는지 안전을 위해 같이 확인해도 될까요?"
|
next_line = "그 생각이 최근에 얼마나 자주, 얼마나 강하게 올라오는지 안전을 위해 같이 확인해도 될까요?"
|
||||||
|
elif q and any(k in q for k in ("저항", "방어", "닫", "안 열", "거부")):
|
||||||
|
focus = "rapport"
|
||||||
|
title = "저항 수용과 안전감"
|
||||||
|
message = "내담자의 방어는 자연스러운 안전 전략입니다. 방어를 깨뜨리려 하지 말고 그 부담감을 먼저 수용해 주세요."
|
||||||
|
next_line = "지금 이 이야기를 꺼내는 것 자체가 많이 조심스럽고 부담되셨을 것 같아요."
|
||||||
|
elif q and any(k in q for k in ("공감", "라포", "위로", "마음", "감정")):
|
||||||
|
focus = "emotion"
|
||||||
|
title = "공감적 감정 명명"
|
||||||
|
message = "내담자가 겪고 있는 혼란스러운 마음에 이름을 붙여주고, 타당화하는 반영을 시도하세요."
|
||||||
|
next_line = "혼자서 그 모든 감정을 감당하느라 많이 지치고 외로우셨을 것 같습니다."
|
||||||
|
elif q and any(k in q for k in ("탐색", "질문", "원인", "호소", "사건")):
|
||||||
|
focus = "exploration"
|
||||||
|
title = "열린 초점 탐색"
|
||||||
|
message = "구체적인 촉발 사건과 그때의 신체 반응이나 생각을 묻는 열린 질문을 던져보세요."
|
||||||
|
next_line = "그런 생각이 들었을 때, 몸에서는 어떤 느낌이나 신호가 먼저 느껴졌나요?"
|
||||||
|
elif q and any(k in q for k in ("다음", "추천", "어떻게", "뭐라", "발화")):
|
||||||
|
focus = "exploration"
|
||||||
|
title = "다음 개입 추천"
|
||||||
|
message = f"현재 [{item.stage}] 단계 흐름에 맞춰 내담자의 감정을 먼저 수용하고 열린 질문으로 이어가세요."
|
||||||
|
next_line = stage_next_lines.get(str(item.stage), "그 상황에서 가장 크게 느껴졌던 마음은 어떤 것이었나요?")
|
||||||
elif any(word in text for word in ("해야", "해봐", "괜찮아", "그냥", "왜 안")):
|
elif any(word in text for word in ("해야", "해봐", "괜찮아", "그냥", "왜 안")):
|
||||||
tone = "warn"
|
tone = "warn"
|
||||||
focus = "rapport"
|
focus = "rapport"
|
||||||
|
|
@ -656,6 +678,11 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
|
||||||
)
|
)
|
||||||
or "(이번 회기 첫 코칭)"
|
or "(이번 회기 첫 코칭)"
|
||||||
)
|
)
|
||||||
|
question_block = (
|
||||||
|
f"[상담자의 질문/요청]\n{_mask_prompt_text(item.question)}\n(상담자의 질문에 집중하여 조언과 추천 발화를 생성한다)\n\n"
|
||||||
|
if item.question
|
||||||
|
else ""
|
||||||
|
)
|
||||||
user = (
|
user = (
|
||||||
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
|
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
|
||||||
f"[내담자] {item.persona_name} ({item.persona_code})\n"
|
f"[내담자] {item.persona_name} ({item.persona_code})\n"
|
||||||
|
|
@ -663,6 +690,7 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
|
||||||
f"[이번 회기 목표 단계] {goals} — 코칭은 목표 단계 작업에 정렬하고, 목표를 이미 이뤘다면 심화를 제안한다.\n"
|
f"[이번 회기 목표 단계] {goals} — 코칭은 목표 단계 작업에 정렬하고, 목표를 이미 이뤘다면 심화를 제안한다.\n"
|
||||||
f"[직전 코칭]\n{prior}\n(같은 조언을 반복하지 말고 다음 단계를 제시한다)\n\n"
|
f"[직전 코칭]\n{prior}\n(같은 조언을 반복하지 말고 다음 단계를 제시한다)\n\n"
|
||||||
f"[최근 맥락]\n{recent}\n\n"
|
f"[최근 맥락]\n{recent}\n\n"
|
||||||
|
f"{question_block}"
|
||||||
f"[이번 상담자 발화]\n{learner_masked}\n\n"
|
f"[이번 상담자 발화]\n{learner_masked}\n\n"
|
||||||
f"[이어진 내담자 응답]\n{client_masked or '(아직 없음)'}\n\n"
|
f"[이어진 내담자 응답]\n{client_masked or '(아직 없음)'}\n\n"
|
||||||
f"[fast-loop 평가 신호]\n{evaluation}\n\n"
|
f"[fast-loop 평가 신호]\n{evaluation}\n\n"
|
||||||
|
|
|
||||||
4
apps/api/engine.public.err.log.2026-08-07T2200.bak
Normal file
4
apps/api/engine.public.err.log.2026-08-07T2200.bak
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
INFO: Started server process [8752]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://127.0.0.1:9099 (Press CTRL+C to quit)
|
||||||
4
apps/api/engine.public.err.log.20260809-160042.bak
Normal file
4
apps/api/engine.public.err.log.20260809-160042.bak
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
INFO: Started server process [69132]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://127.0.0.1:9099 (Press CTRL+C to quit)
|
||||||
2235
apps/api/engine.public.out.log.2026-08-07T2200.bak
Normal file
2235
apps/api/engine.public.out.log.2026-08-07T2200.bak
Normal file
File diff suppressed because it is too large
Load diff
150
apps/api/engine.public.out.log.20260809-160042.bak
Normal file
150
apps/api/engine.public.out.log.20260809-160042.bak
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
INFO: 127.0.0.1:5961 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1131 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:13224 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1131 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9011 - "GET /ready?force=true HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4872 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4872 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9735 - "GET /ready HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9737 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9737 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9550 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4478 - "GET /ready HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4483 - "GET /ready HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:6909 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:6920 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:6920 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4284 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11183 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11186 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11186 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9906 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9908 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9908 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4910 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4912 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4912 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11462 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11464 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11464 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3395 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3397 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3397 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7131 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7135 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7135 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4662 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4665 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4665 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8921 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8924 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8924 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7827 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7830 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7830 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11042 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11042 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:14342 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:14343 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:14342 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5041 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5043 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5043 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:12732 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:12735 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:12735 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2362 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2365 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2365 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4926 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4928 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4928 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4810 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4813 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4813 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2345 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2351 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2351 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2075 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2079 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2079 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3840 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3842 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3842 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4284 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:10589 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:10591 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:10591 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8330 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5069 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5071 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5071 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8707 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8709 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8709 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9467 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9470 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:9470 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11371 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11371 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11382 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11416 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11382 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11850 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11852 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:11852 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1574 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1576 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1576 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4470 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4472 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4472 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4562 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4564 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4564 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7321 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7323 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7323 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1317 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1319 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:1319 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2332 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3770 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3772 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:3772 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7472 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7474 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7474 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7671 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7673 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:7673 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8837 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8839 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:8839 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2461 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2463 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:2463 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:10930 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5805 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5781 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:5714 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4865 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:4903 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
|
||||||
|
|
@ -177,16 +177,43 @@ async function routeCoachVoiceSession(page: Page, options: CoachVoiceOptions = {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
|
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, async (route) => {
|
||||||
route.fulfill(
|
if (route.request().method() === "POST") {
|
||||||
|
await route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
status: "ready",
|
||||||
|
tone: "pos",
|
||||||
|
focus: "emotion",
|
||||||
|
title: "공감과 탐색",
|
||||||
|
message: "내담자의 답답한 마음에 먼저 공감하고 구체적인 장면을 열린 질문으로 물어보세요.",
|
||||||
|
next_utterance: "학교 생각만 하면 답답하다고 했는데, 어떤 부분이 제일 마음에 걸려요?",
|
||||||
|
rationale: "감정 타당화 후 구체 촉발 탐색",
|
||||||
|
quota: { remaining: Math.max(0, coachRemaining - 1), max: coachMax },
|
||||||
|
credit_events: [
|
||||||
|
{
|
||||||
|
event_id: "c-001",
|
||||||
|
session_id: SESSION_ID,
|
||||||
|
turn_seq: 1,
|
||||||
|
event_type: "use",
|
||||||
|
delta: -1,
|
||||||
|
balance: Math.max(0, coachRemaining - 1),
|
||||||
|
reason: "live coaching request",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill(
|
||||||
jsonRoute({
|
jsonRoute({
|
||||||
source: "database",
|
source: "database",
|
||||||
quota: { remaining: coachRemaining, max: coachMax },
|
quota: { remaining: coachRemaining, max: coachMax },
|
||||||
credit_events: [],
|
credit_events: [],
|
||||||
events: [],
|
events: [],
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSession(page: Page) {
|
async function openSession(page: Page) {
|
||||||
|
|
@ -419,4 +446,49 @@ test.describe("AI 코치·음성 UI — 고지·게이트·피드백 모드", ()
|
||||||
const label = await mic.getAttribute("aria-label");
|
const label = await mic.getAttribute("aria-label");
|
||||||
expect(label ?? "").not.toBe("");
|
expect(label ?? "").not.toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// usecase: AI 코칭 버튼 클릭 시 버블 팝업이 노출되고 추천 칩 및 채팅 조언을 얻을 수 있다
|
||||||
|
test("AI 코칭 버튼 클릭 시 버블 팝업이 뜨고 채팅 질문과 추천 발화 적용이 동작한다", async ({ page }) => {
|
||||||
|
await routeCoachVoiceSession(page, { coachRemaining: 3, coachMax: 3 });
|
||||||
|
await openSession(page);
|
||||||
|
|
||||||
|
const triggerBtn = page.getByRole("button", { name: /^AI 코칭, 남은 기회/ });
|
||||||
|
await expect(triggerBtn).toBeVisible();
|
||||||
|
await expect(triggerBtn).toContainText("3");
|
||||||
|
|
||||||
|
// 1. 버블 팝업 열기
|
||||||
|
await triggerBtn.click({ force: true });
|
||||||
|
const popup = page.getByRole("dialog", { name: "AI 슈퍼바이저 코칭 버블" });
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup).toContainText("AI 슈퍼바이저 코칭");
|
||||||
|
await expect(popup).toContainText("기회 3/3");
|
||||||
|
|
||||||
|
// 2. 추천 칩 확인 및 클릭
|
||||||
|
const recommendChip = popup.getByRole("button", { name: "💡 다음 추천 발화" });
|
||||||
|
await expect(recommendChip).toBeVisible();
|
||||||
|
await recommendChip.dispatchEvent("click");
|
||||||
|
|
||||||
|
// 3. 조언 메시지 및 추천 발화 노출 확인
|
||||||
|
await expect(popup.locator(".sx-coach-msg--learner")).toContainText("다음 추천 발화와 방향을 알려줘");
|
||||||
|
await expect(popup.locator(".sx-coach-msg--coach")).toBeVisible();
|
||||||
|
const insertBtn = popup.getByRole("button", { name: "발화창에 넣기" });
|
||||||
|
await expect(insertBtn).toBeAttached();
|
||||||
|
|
||||||
|
// 4. [발화창에 넣기] 클릭 시 하단 발화 입력창에 텍스트 반영
|
||||||
|
await insertBtn.dispatchEvent("click");
|
||||||
|
const composer = page.getByPlaceholder("학습자 발화를 입력하세요.");
|
||||||
|
await expect(composer).not.toHaveValue("");
|
||||||
|
|
||||||
|
// 5. 직접 질문 채팅 입력 및 전송
|
||||||
|
const coachInput = popup.getByPlaceholder("코치에게 조언을 구해보세요... (Enter 전송)");
|
||||||
|
await coachInput.fill("내담자의 저항을 부드럽게 다루려면 어떻게 하나요?");
|
||||||
|
await popup.getByRole("button", { name: "코칭 질문 전송" }).dispatchEvent("click");
|
||||||
|
|
||||||
|
await expect(popup.locator(".sx-coach-msg--learner").last()).toContainText("내담자의 저항을 부드럽게 다루려면");
|
||||||
|
await expect(popup.locator(".sx-coach-msg--coach").last()).toBeVisible();
|
||||||
|
|
||||||
|
// 6. 팝업 닫기 버튼 클릭 시 닫힘 확인
|
||||||
|
await popup.getByRole("button", { name: "코칭 팝업 닫기" }).dispatchEvent("click");
|
||||||
|
await expect(popup).toBeHidden();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,17 @@ const designDoc = await read(designDocPath);
|
||||||
|
|
||||||
// 페이지/아트 전용 CSS는 집중 화면의 국소 팔레트를 소유할 수 있다. 다만 전역
|
// 페이지/아트 전용 CSS는 집중 화면의 국소 팔레트를 소유할 수 있다. 다만 전역
|
||||||
// 토큰으로 승격하지 않은 raw color는 이 기준선 이상 늘어나면 실패시켜 예외가
|
// 토큰으로 승격하지 않은 raw color는 이 기준선 이상 늘어나면 실패시켜 예외가
|
||||||
// 무제한 확산되는 것을 막는다. 공통 ui/shell은 아래 별도 규칙으로 0개를 강제한다.
|
// 2026-08-18: 전수 토큰화 완료. 모든 페이지와 컴포넌트 CSS에서 raw color가 0개로 정화됨.
|
||||||
const rawColorBudgets = {
|
const rawColorBudgets = {
|
||||||
"src/pages/session/session.css": 216,
|
"src/pages/session/session.css": 0,
|
||||||
"src/pages/session-review/session-review.css": 29,
|
"src/pages/session-review/session-review.css": 0,
|
||||||
"src/pages/learner-home.css": 25,
|
"src/pages/learner-home.css": 0,
|
||||||
"src/components/auth/auth-shell.css": 20,
|
"src/components/auth/auth-shell.css": 0,
|
||||||
"src/pages/login/login.css": 11,
|
"src/pages/login/login.css": 0,
|
||||||
"src/components/avatar/client-avatar.css": 7,
|
"src/components/avatar/client-avatar.css": 0,
|
||||||
"src/pages/avatar-expression-lab.css": 7,
|
"src/pages/avatar-expression-lab.css": 0,
|
||||||
"src/pages/avatar-preview.css": 5,
|
"src/pages/avatar-preview.css": 0,
|
||||||
"src/pages/pending-approval.css": 3,
|
"src/pages/pending-approval.css": 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [relativePath, budget] of Object.entries(rawColorBudgets)) {
|
for (const [relativePath, budget] of Object.entries(rawColorBudgets)) {
|
||||||
|
|
|
||||||
|
|
@ -41,17 +41,17 @@
|
||||||
--auth-room-veil:
|
--auth-room-veil:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(247, 244, 236, 0.94) 0%,
|
color-mix(in srgb, var(--bg-app) 94%, transparent) 0%,
|
||||||
rgba(243, 238, 227, 0.86) 48%,
|
color-mix(in srgb, var(--bg-app) 86%, transparent) 48%,
|
||||||
rgba(238, 231, 217, 0.72) 74%,
|
color-mix(in srgb, var(--bg-app) 72%, transparent) 74%,
|
||||||
rgba(232, 223, 207, 0.62) 100%
|
color-mix(in srgb, var(--bg-app) 62%, transparent) 100%
|
||||||
);
|
);
|
||||||
--auth-room-shade: linear-gradient(180deg, rgba(255, 253, 248, 0.08), rgba(120, 101, 72, 0.12));
|
--auth-room-shade: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface) 8%, transparent), color-mix(in srgb, var(--clay) 12%, transparent));
|
||||||
--auth-hero-text: var(--text-strong);
|
--auth-hero-text: var(--text-strong);
|
||||||
--auth-hero-muted: color-mix(in srgb, var(--text-body) 88%, transparent);
|
--auth-hero-muted: color-mix(in srgb, var(--text-body) 88%, transparent);
|
||||||
--auth-hero-surface: rgba(255, 254, 251, 0.48);
|
--auth-hero-surface: color-mix(in srgb, var(--bg-surface) 48%, transparent);
|
||||||
--auth-hero-border: rgba(101, 86, 62, 0.22);
|
--auth-hero-border: color-mix(in srgb, var(--border-strong) 42%, transparent);
|
||||||
--auth-hero-chip: rgba(255, 254, 251, 0.62);
|
--auth-hero-chip: color-mix(in srgb, var(--bg-surface) 62%, transparent);
|
||||||
background:
|
background:
|
||||||
var(--auth-room-veil),
|
var(--auth-room-veil),
|
||||||
var(--asset-login-room) center / cover no-repeat;
|
var(--asset-login-room) center / cover no-repeat;
|
||||||
|
|
@ -61,17 +61,17 @@
|
||||||
--auth-room-veil:
|
--auth-room-veil:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(7, 16, 14, 0.84) 0%,
|
color-mix(in srgb, var(--bg-app) 84%, transparent) 0%,
|
||||||
rgba(10, 21, 19, 0.72) 48%,
|
color-mix(in srgb, var(--bg-app) 72%, transparent) 48%,
|
||||||
rgba(10, 21, 19, 0.55) 74%,
|
color-mix(in srgb, var(--bg-app) 55%, transparent) 74%,
|
||||||
rgba(10, 21, 19, 0.62) 100%
|
color-mix(in srgb, var(--bg-app) 62%, transparent) 100%
|
||||||
);
|
);
|
||||||
--auth-room-shade: linear-gradient(180deg, rgba(3, 9, 8, 0.18), rgba(3, 9, 8, 0.42));
|
--auth-room-shade: linear-gradient(180deg, color-mix(in srgb, var(--bg-app) 18%, transparent), color-mix(in srgb, var(--bg-app) 42%, transparent));
|
||||||
--auth-hero-text: #edf4f2;
|
--auth-hero-text: var(--text-strong);
|
||||||
--auth-hero-muted: rgba(237, 244, 242, 0.72);
|
--auth-hero-muted: color-mix(in srgb, var(--text-strong) 72%, transparent);
|
||||||
--auth-hero-surface: rgba(237, 244, 242, 0.08);
|
--auth-hero-surface: color-mix(in srgb, var(--text-strong) 8%, transparent);
|
||||||
--auth-hero-border: rgba(255, 255, 255, 0.14);
|
--auth-hero-border: color-mix(in srgb, var(--border-subtle) 80%, transparent);
|
||||||
--auth-hero-chip: rgba(255, 255, 255, 0.08);
|
--auth-hero-chip: color-mix(in srgb, var(--text-strong) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@
|
||||||
position:relative;width:100%;border-radius:50%;
|
position:relative;width:100%;border-radius:50%;
|
||||||
display:flex;align-items:center;justify-content:center;overflow:hidden;
|
display:flex;align-items:center;justify-content:center;overflow:hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%);
|
radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--bg-surface) 98%, transparent) 0%, color-mix(in srgb, var(--bg-surface-2) 90%, transparent) 42%, color-mix(in srgb, var(--accent-tint) 76%, transparent) 64%, color-mix(in srgb, var(--accent) 22%, transparent) 84%, color-mix(in srgb, var(--text-strong) 18%, transparent) 100%);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 0 1px rgba(251,250,248,.28),
|
inset 0 0 0 1px color-mix(in srgb, var(--bg-surface) 28%, transparent),
|
||||||
inset 0 -24px 40px rgba(28,42,42,.13);
|
inset 0 -24px 40px color-mix(in srgb, var(--text-strong) 13%, transparent);
|
||||||
}
|
}
|
||||||
.vg-avatar__aura{
|
.vg-avatar__aura{
|
||||||
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
|
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Topbar } from "./Topbar";
|
import { Topbar } from "./Topbar";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth";
|
import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth";
|
||||||
|
|
@ -78,6 +78,79 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
||||||
return () => window.removeEventListener("pageshow", handlePageShow);
|
return () => window.removeEventListener("pageshow", handlePageShow);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// ── 전역 단축키 핸들러 ──
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.repeat) return;
|
||||||
|
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||||
|
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
|
||||||
|
|
||||||
|
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
|
||||||
|
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
|
||||||
|
|
||||||
|
// Ctrl+W 또는 Escape: 활성 모달 닫기
|
||||||
|
if ((isCtrl && e.key.toLowerCase() === "w") || e.key === "Escape") {
|
||||||
|
const openModalCloseBtn = document.querySelector<HTMLButtonElement>(
|
||||||
|
"[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']"
|
||||||
|
);
|
||||||
|
if (openModalCloseBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
openModalCloseBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+H: 홈으로 이동
|
||||||
|
if (isAlt && e.key.toLowerCase() === "h") {
|
||||||
|
e.preventDefault();
|
||||||
|
const homePath = user?.role === "admin" ? "/admin" : user?.role === "teacher" ? "/professor" : "/learn";
|
||||||
|
navigate(homePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+N 또는 Ctrl+N 또는 Ctrl+T (입력창 밖): 새 회기 시작 / 페르소나 선택
|
||||||
|
if ((isAlt && e.key.toLowerCase() === "n") || (!isInput && isCtrl && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "t"))) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/learn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+K: 학습 기록으로 이동
|
||||||
|
if (isAlt && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/learn/history");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+S: 설정 화면으로 이동
|
||||||
|
if (isAlt && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+T: 다크/라이트 테마 전환
|
||||||
|
if (isAlt && e.key.toLowerCase() === "t") {
|
||||||
|
e.preventDefault();
|
||||||
|
const themeBtn = document.querySelector<HTMLButtonElement>(".vg-topbar__theme-btn, [data-testid='theme-toggle']");
|
||||||
|
if (themeBtn) {
|
||||||
|
themeBtn.click();
|
||||||
|
} else {
|
||||||
|
const currentTheme = document.documentElement.getAttribute("data-theme") || "light";
|
||||||
|
const nextTheme = currentTheme === "dark" ? "light" : "dark";
|
||||||
|
document.documentElement.setAttribute("data-theme", nextTheme);
|
||||||
|
localStorage.setItem("vignette_theme", nextTheme);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||||
|
}, [navigate, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }:
|
||||||
<span className="vg-nav__ic">
|
<span className="vg-nav__ic">
|
||||||
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
||||||
</span>
|
</span>
|
||||||
<span>{item.label}</span>
|
<span className="vg-nav__txt">{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,10 @@
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.vg-topbar__switch-link:active {
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
.vg-topbar__switch-link:hover,
|
.vg-topbar__switch-link:hover,
|
||||||
.vg-topbar__switch-link.is-active {
|
.vg-topbar__switch-link.is-active {
|
||||||
|
|
@ -125,9 +129,13 @@
|
||||||
color: var(--text-body);
|
color: var(--text-body);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
|
transform var(--dur-fast) var(--ease-spring),
|
||||||
background var(--dur-fast) var(--ease-out),
|
background var(--dur-fast) var(--ease-out),
|
||||||
color var(--dur-fast) var(--ease-out);
|
color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
.vg-iconbtn:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
.vg-iconbtn:hover {
|
.vg-iconbtn:hover {
|
||||||
background: var(--bg-surface-2);
|
background: var(--bg-surface-2);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
|
@ -324,33 +332,46 @@
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
html {
|
html {
|
||||||
scroll-padding-top: calc(var(--topbar-h) + 68px);
|
scroll-padding-top: calc(var(--topbar-h) + var(--sp-2));
|
||||||
scroll-padding-bottom: var(--sp-6);
|
scroll-padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-4));
|
||||||
|
}
|
||||||
|
.vg-shell {
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
.vg-shell__body {
|
.vg-shell__body {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
flex-direction: column;
|
||||||
grid-template-rows: 58px minmax(0, 1fr);
|
flex: 1 1 auto;
|
||||||
background-image: none; /* 네비가 상단 가로바로 전환 — 세로 구분선 제거 */
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
background-image: none; /* 네비가 하단바로 전환 — 세로 구분선 제거 */
|
||||||
}
|
}
|
||||||
.vg-shell__body--bare {
|
.vg-shell__body--bare {
|
||||||
display: block;
|
display: flex;
|
||||||
}
|
}
|
||||||
.vg-nav {
|
.vg-nav {
|
||||||
position: relative;
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
top: auto;
|
top: auto;
|
||||||
z-index: 29;
|
z-index: 40;
|
||||||
height: 58px;
|
height: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom));
|
||||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
padding: 6px max(10px, env(safe-area-inset-left)) max(6px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-top: 0;
|
border-top: 1px solid var(--hair);
|
||||||
border-bottom: 1px solid var(--hair);
|
border-bottom: 0;
|
||||||
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: space-around;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
}
|
}
|
||||||
.vg-nav__label,
|
.vg-nav__label,
|
||||||
.vg-nav__spacer,
|
.vg-nav__spacer,
|
||||||
|
|
@ -358,16 +379,43 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.vg-nav__item {
|
.vg-nav__item {
|
||||||
min-width: 112px;
|
flex: 1 1 0;
|
||||||
height: 44px;
|
min-width: 0;
|
||||||
|
max-width: 96px;
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 3px 2px;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 14px;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 2px;
|
||||||
white-space: nowrap;
|
border-radius: var(--radius);
|
||||||
|
text-align: center;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
.vg-nav__item:active {
|
||||||
|
transform: scale(0.93);
|
||||||
|
}
|
||||||
|
.vg-nav__item .vg-nav__ic {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.vg-nav__item .vg-nav__ic svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.vg-nav__item .vg-nav__txt,
|
||||||
.vg-nav__item span:not(.vg-nav__ic) {
|
.vg-nav__item span:not(.vg-nav__ic) {
|
||||||
display: inline;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.vg-topbar__role {
|
.vg-topbar__role {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -376,50 +424,47 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.vg-topbar {
|
.vg-topbar {
|
||||||
padding: 0 4px;
|
height: 50px;
|
||||||
gap: 4px;
|
min-height: 50px;
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.vg-topbar__brand {
|
.vg-topbar__brand {
|
||||||
width: 44px;
|
min-height: 44px;
|
||||||
height: 44px;
|
gap: 8px;
|
||||||
flex: 0 0 44px;
|
}
|
||||||
justify-content: center;
|
.vg-topbar__mark {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.vg-topbar__wm {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.vg-topbar__wm,
|
|
||||||
.vg-topbar__user {
|
.vg-topbar__user {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.vg-topbar__actions {
|
.vg-topbar__actions {
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.vg-topbar__switch {
|
.vg-topbar__switch {
|
||||||
flex: 1 1 auto;
|
height: 38px;
|
||||||
height: 44px;
|
padding: 2px;
|
||||||
padding: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.vg-topbar__switch::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.vg-topbar__switch-link span {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
.vg-topbar__switch-link {
|
.vg-topbar__switch-link {
|
||||||
width: 44px;
|
min-height: 34px;
|
||||||
height: 44px;
|
padding: 0 8px;
|
||||||
flex: 0 0 44px;
|
font-size: 11.5px;
|
||||||
padding: 0;
|
}
|
||||||
|
.vg-topbar__switch-link span {
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
.vg-iconbtn {
|
.vg-iconbtn {
|
||||||
width: 44px;
|
width: 40px;
|
||||||
height: 44px;
|
height: 40px;
|
||||||
flex: 0 0 44px;
|
|
||||||
}
|
}
|
||||||
.vg-main {
|
.vg-main {
|
||||||
padding: var(--sp-5) var(--sp-4) var(--sp-7);
|
padding: var(--sp-4) var(--sp-4) calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
||||||
}
|
}
|
||||||
.vg-main--bleed {
|
.vg-main--bleed {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -584,9 +629,8 @@ body[data-role] .vg-shell--botanical .vg-nav__foot {
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
flex-direction: column;
|
||||||
grid-template-rows: 58px minmax(0, 1fr);
|
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle at 74% 12%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 22rem),
|
radial-gradient(circle at 74% 12%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 22rem),
|
||||||
radial-gradient(circle at 18% 82%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 28rem),
|
radial-gradient(circle at 18% 82%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 28rem),
|
||||||
|
|
@ -596,26 +640,76 @@ body[data-role] .vg-shell--botanical .vg-nav__foot {
|
||||||
background-size: 100% 34rem, 100% 38rem, 430px auto;
|
background-size: 100% 34rem, 100% 38rem, 430px auto;
|
||||||
}
|
}
|
||||||
body[data-role] .vg-shell--botanical .vg-nav {
|
body[data-role] .vg-shell--botanical .vg-nav {
|
||||||
position: relative;
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
top: auto;
|
top: auto;
|
||||||
z-index: 29;
|
z-index: 40;
|
||||||
height: 58px;
|
height: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom));
|
||||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
padding: 6px max(10px, env(safe-area-inset-left)) max(6px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||||
border-bottom: 1px solid var(--botanical-hair);
|
border-top: 1px solid var(--botanical-hair);
|
||||||
background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
|
border-bottom: 0;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-surface) 86%, transparent);
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
-webkit-backdrop-filter: var(--glass-blur);
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
backdrop-filter: var(--glass-blur);
|
backdrop-filter: var(--glass-blur);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
body[data-role] .vg-shell--botanical .vg-nav__item,
|
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||||
min-width: 112px;
|
flex: 1 1 0;
|
||||||
height: 44px;
|
min-width: 0;
|
||||||
padding: 0 14px;
|
max-width: 96px;
|
||||||
gap: 8px;
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 3px 2px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||||
|
padding-right: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
112deg,
|
||||||
|
color-mix(in srgb, var(--bg-surface) 92%, var(--botanical-gold)),
|
||||||
|
color-mix(in srgb, var(--bg-surface-2) 80%, transparent)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--botanical-gold) 34%, var(--border-subtle));
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 84%, transparent);
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item .vg-nav__ic svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item .vg-nav__txt,
|
||||||
|
body[data-role] .vg-shell--botanical .vg-nav__item span:not(.vg-nav__ic) {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 학습자는 네 항목이 고정이므로 숨은 가로 탐색보다 한 화면 4등분이 예측 가능하다.
|
|
||||||
교수자·관리자처럼 항목 수가 많은 역할은 기존 가로 스크롤 계약을 유지한다. */
|
|
||||||
body[data-role="learner"] .vg-shell--botanical .vg-nav {
|
body[data-role="learner"] .vg-shell--botanical .vg-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|
@ -625,17 +719,9 @@ body[data-role] .vg-shell--botanical .vg-nav__foot {
|
||||||
body[data-role="learner"] .vg-shell--botanical .vg-nav__item,
|
body[data-role="learner"] .vg-shell--botanical .vg-nav__item,
|
||||||
body[data-role="learner"] .vg-shell--botanical .vg-nav__item.is-active {
|
body[data-role="learner"] .vg-shell--botanical .vg-nav__item.is-active {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height:44px;
|
height: 48px;
|
||||||
padding:0 2px;
|
padding: 3px 2px;
|
||||||
gap:3px;
|
|
||||||
font-size:12px;
|
|
||||||
}
|
|
||||||
body[data-role="learner"] .vg-shell--botanical .vg-nav__item span:not(.vg-nav__ic) {
|
|
||||||
font-size:12px;
|
|
||||||
}
|
|
||||||
body[data-role="learner"] .vg-shell--botanical .vg-nav__ic svg {
|
|
||||||
width:16px;
|
|
||||||
height:16px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export type IconName =
|
||||||
| "school"
|
| "school"
|
||||||
| "sun"
|
| "sun"
|
||||||
| "moon"
|
| "moon"
|
||||||
|
| "copy"
|
||||||
| "refresh";
|
| "refresh";
|
||||||
|
|
||||||
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||||
|
|
@ -227,6 +228,12 @@ const PATHS: Record<IconName, ReactNode> = {
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
|
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
|
||||||
|
copy: (
|
||||||
|
<>
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Icon({
|
export function Icon({
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,14 @@
|
||||||
transition:
|
transition:
|
||||||
background var(--dur-base) var(--ease-out),
|
background var(--dur-base) var(--ease-out),
|
||||||
border-color var(--dur-base) var(--ease-out),
|
border-color var(--dur-base) var(--ease-out),
|
||||||
transform var(--dur-fast) var(--ease-out);
|
transform var(--dur-fast) var(--ease-spring),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.vg-btn:active {
|
.vg-btn:active {
|
||||||
transform: translateY(0.5px);
|
transform: translateY(1px) scale(0.98);
|
||||||
}
|
}
|
||||||
.vg-btn:disabled {
|
.vg-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
|
||||||
|
|
@ -6108,6 +6108,8 @@ export interface components {
|
||||||
client_reply?: string | null;
|
client_reply?: string | null;
|
||||||
/** Learner Text */
|
/** Learner Text */
|
||||||
learner_text: string;
|
learner_text: string;
|
||||||
|
/** Question */
|
||||||
|
question?: string | null;
|
||||||
/** Turn Seq */
|
/** Turn Seq */
|
||||||
turn_seq?: number | null;
|
turn_seq?: number | null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,14 @@ const THEORY_MODE_OPTIONS: {
|
||||||
];
|
];
|
||||||
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
|
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
|
||||||
|
|
||||||
|
interface CoachChatMessage {
|
||||||
|
id: string;
|
||||||
|
sender: "learner" | "coach";
|
||||||
|
text: string;
|
||||||
|
suggestion?: LiveCoachSuggestion;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
|
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
|
||||||
const firstSupported = summary?.theory_target.find(
|
const firstSupported = summary?.theory_target.find(
|
||||||
(target): target is TheoryMode =>
|
(target): target is TheoryMode =>
|
||||||
|
|
@ -492,7 +500,10 @@ export default function Session() {
|
||||||
const goalNudgeShownRef = useRef(false);
|
const goalNudgeShownRef = useRef(false);
|
||||||
const [timeUp, setTimeUp] = useState(false);
|
const [timeUp, setTimeUp] = useState(false);
|
||||||
|
|
||||||
// ── 패널 컬랩싱(과밀 완화) — 접힌 패널은 상황에 맞춰 넛지/강제 오픈 ──
|
// ── 패널 컬랩싱(과밀 완화 및 몰입 확장) — 접힌 패널은 상황에 맞춰 넛지/강제 오픈 ──
|
||||||
|
const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(false);
|
||||||
|
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
|
||||||
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
||||||
const [ctxCollapsed, setCtxCollapsed] = useState(true);
|
const [ctxCollapsed, setCtxCollapsed] = useState(true);
|
||||||
const [metersCollapsed, setMetersCollapsed] = useState(false);
|
const [metersCollapsed, setMetersCollapsed] = useState(false);
|
||||||
const [safetyCollapsed, setSafetyCollapsed] = useState(true);
|
const [safetyCollapsed, setSafetyCollapsed] = useState(true);
|
||||||
|
|
@ -525,6 +536,15 @@ export default function Session() {
|
||||||
const coachCreditSeenRef = useRef<Set<string>>(new Set());
|
const coachCreditSeenRef = useRef<Set<string>>(new Set());
|
||||||
const coachCreditPulseTimerRef = useRef<number | null>(null);
|
const coachCreditPulseTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// ── AI 코칭 버블 팝업 상태 ──
|
||||||
|
const [isCoachBubbleOpen, setIsCoachBubbleOpen] = useState(false);
|
||||||
|
const [coachChatInput, setCoachChatInput] = useState("");
|
||||||
|
const [coachChatLoading, setCoachChatLoading] = useState(false);
|
||||||
|
const [coachChatMessages, setCoachChatMessages] = useState<CoachChatMessage[]>([]);
|
||||||
|
const coachBubbleAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const coachChatInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const coachChatScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
activeVoiceSessionRef.current = liveSessionId;
|
activeVoiceSessionRef.current = liveSessionId;
|
||||||
|
|
||||||
const failPendingVoiceLearnerTurn = useCallback(() => {
|
const failPendingVoiceLearnerTurn = useCallback(() => {
|
||||||
|
|
@ -610,6 +630,9 @@ export default function Session() {
|
||||||
coachCreditSeenRef.current = new Set();
|
coachCreditSeenRef.current = new Set();
|
||||||
setCoachHistoryOpen(false);
|
setCoachHistoryOpen(false);
|
||||||
setCoachHistoryTurnSeq(null);
|
setCoachHistoryTurnSeq(null);
|
||||||
|
setIsCoachBubbleOpen(false);
|
||||||
|
setCoachChatMessages([]);
|
||||||
|
setCoachChatInput("");
|
||||||
}, [navigate, routeIsSessionId, routeParam]);
|
}, [navigate, routeIsSessionId, routeParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1019,6 +1042,158 @@ export default function Session() {
|
||||||
void requestLiveCoach(payload);
|
void requestLiveCoach(payload);
|
||||||
}, [feedbackMode, pendingCoachTurn, requestLiveCoach]);
|
}, [feedbackMode, pendingCoachTurn, requestLiveCoach]);
|
||||||
|
|
||||||
|
// ── AI 코칭 버블 팝업 제어 및 질의응답 ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCoachBubbleOpen) return;
|
||||||
|
|
||||||
|
const focusTimer = window.setTimeout(() => {
|
||||||
|
coachChatInputRef.current?.focus();
|
||||||
|
}, 60);
|
||||||
|
|
||||||
|
const handlePointerDownOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
coachBubbleAnchorRef.current &&
|
||||||
|
!coachBubbleAnchorRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setIsCoachBubbleOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setIsCoachBubbleOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDownOutside);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(focusTimer);
|
||||||
|
document.removeEventListener("mousedown", handlePointerDownOutside);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isCoachBubbleOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCoachBubbleOpen && coachChatScrollRef.current) {
|
||||||
|
coachChatScrollRef.current.scrollTop = coachChatScrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [coachChatMessages, coachChatLoading, isCoachBubbleOpen]);
|
||||||
|
|
||||||
|
const handleSendCoachBubbleQuery = useCallback(
|
||||||
|
async (queryText: string) => {
|
||||||
|
if (!liveSessionId || !queryText.trim()) return;
|
||||||
|
if (coachQuota.remaining <= 0) {
|
||||||
|
setCoachChatMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: randomUuid(),
|
||||||
|
sender: "coach",
|
||||||
|
text: "코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리거나 회기가 진행되면 충전됩니다.",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsgId = randomUuid();
|
||||||
|
setCoachChatMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: userMsgId,
|
||||||
|
sender: "learner",
|
||||||
|
text: queryText,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setCoachChatLoading(true);
|
||||||
|
|
||||||
|
const lastLearnerUtterance = [...utterances]
|
||||||
|
.reverse()
|
||||||
|
.find((u) => u.speaker === "learner" && !u.partial && !u.failed);
|
||||||
|
const lastClientUtterance = [...utterances]
|
||||||
|
.reverse()
|
||||||
|
.find((u) => u.speaker === "client" && !u.partial && !u.failed);
|
||||||
|
|
||||||
|
const learnerTextToSend =
|
||||||
|
composeText.trim() ||
|
||||||
|
lastLearnerUtterance?.text ||
|
||||||
|
"상담 진행 및 다음 개입 방향 조언 요청";
|
||||||
|
const clientReplyToSend = lastClientUtterance?.text ?? null;
|
||||||
|
const turnSeqToSend = lastLearnerUtterance?.turnSeq;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suggestion = await sessionApi.liveCoach(liveSessionId, {
|
||||||
|
learner_text: learnerTextToSend,
|
||||||
|
question: queryText,
|
||||||
|
client_reply: clientReplyToSend,
|
||||||
|
turn_seq: turnSeqToSend,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCoachSuggestion(suggestion);
|
||||||
|
setCoachPersistenceSource(suggestion.persistence_source ?? null);
|
||||||
|
if (suggestion.quota) {
|
||||||
|
setCoachQuota(suggestion.quota);
|
||||||
|
}
|
||||||
|
if (suggestion.credit_events?.length) {
|
||||||
|
applyCoachCreditEvents(suggestion.credit_events, true);
|
||||||
|
}
|
||||||
|
pushSignal(suggestion.tone, suggestion.title);
|
||||||
|
void refreshCoachHistory(liveSessionId, { animateCredits: false, warnOnFailure: true });
|
||||||
|
|
||||||
|
setCoachChatMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: randomUuid(),
|
||||||
|
sender: "coach",
|
||||||
|
text: suggestion.message,
|
||||||
|
suggestion,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setCoachQuota((current) => ({ ...current, remaining: 0 }));
|
||||||
|
setCoachChatMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: randomUuid(),
|
||||||
|
sender: "coach",
|
||||||
|
text: "코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리면 다시 1개가 충전됩니다.",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setCoachChatMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: randomUuid(),
|
||||||
|
sender: "coach",
|
||||||
|
text: "코칭 조언을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCoachChatLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
applyCoachCreditEvents,
|
||||||
|
coachQuota.remaining,
|
||||||
|
composeText,
|
||||||
|
liveSessionId,
|
||||||
|
pushSignal,
|
||||||
|
refreshCoachHistory,
|
||||||
|
utterances,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applySuggestionToComposer = useCallback((text: string) => {
|
||||||
|
setComposeText(text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!routeIsSessionId) return;
|
if (!routeIsSessionId) return;
|
||||||
// 이 화면에서 방금 시작한 회기의 URL 전환은 재개(resume)가 아니다 — 서버 스냅샷(0턴)
|
// 이 화면에서 방금 시작한 회기의 URL 전환은 재개(resume)가 아니다 — 서버 스냅샷(0턴)
|
||||||
|
|
@ -2155,28 +2330,174 @@ export default function Session() {
|
||||||
});
|
});
|
||||||
}, [sessionEnded, shutdownVoice, voiceStatus]);
|
}, [sessionEnded, shutdownVoice, voiceStatus]);
|
||||||
|
|
||||||
// ── 키보드 단축키: Alt+M=마이크, P=일시정지. 입력 중엔 무시 ──
|
// ── 키보드 단축키 전수 등록 ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!started) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "m") {
|
|
||||||
|
// 1. 전역 모달 닫기: Escape 또는 Ctrl+W (모달이 열려있을 때)
|
||||||
|
if (e.key === "Escape" || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "w")) {
|
||||||
|
if (shortcutHelpOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
setShortcutHelpOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coachEvidenceOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
setCoachEvidenceOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coachHistoryOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
setCoachHistoryOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (endDialogOpen && !ending) {
|
||||||
|
e.preventDefault();
|
||||||
|
setEndDialogOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 단축키 도움말: ? (입력창 밖) 또는 Ctrl+/ or Alt+/
|
||||||
|
if (
|
||||||
|
((e.ctrlKey || e.metaKey || e.altKey) && e.key === "/") ||
|
||||||
|
(e.key === "?" && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement))
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
setShortcutHelpOpen((prev) => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시작 전 프리플라이트 단축키
|
||||||
|
if (!started) {
|
||||||
|
if ((e.ctrlKey || e.metaKey || e.altKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedGoals.length > 0 && !starting) {
|
||||||
|
void handleStart();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세션 진행 중 단축키
|
||||||
|
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
|
||||||
|
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
|
||||||
|
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||||
|
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
|
||||||
|
|
||||||
|
// Alt+M: 마이크 토글
|
||||||
|
if (isAlt && e.key.toLowerCase() === "m") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void toggleMic();
|
void toggleMic();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
||||||
const interactive = target?.closest(
|
// Alt+[ 또는 Ctrl+[: 좌측 패널 토글
|
||||||
"button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='link']",
|
if ((isAlt || isCtrl) && (e.key === "[" || e.code === "BracketLeft")) {
|
||||||
);
|
e.preventDefault();
|
||||||
if (interactive) return;
|
setLeftPanelCollapsed((prev) => !prev);
|
||||||
if (e.key === "p" || e.key === "P") {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+] 또는 Ctrl+]: 우측 패널 토글
|
||||||
|
if ((isAlt || isCtrl) && (e.key === "]" || e.code === "BracketRight")) {
|
||||||
|
e.preventDefault();
|
||||||
|
setRightPanelCollapsed((prev) => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+\ 또는 Ctrl+\: 완전 몰입 모드 토글 (양쪽 패널 동시 접기/펼치기)
|
||||||
|
if ((isAlt || isCtrl) && (e.key === "\\" || e.code === "Backslash")) {
|
||||||
|
e.preventDefault();
|
||||||
|
const bothCollapsed = leftPanelCollapsed && rightPanelCollapsed;
|
||||||
|
setLeftPanelCollapsed(!bothCollapsed);
|
||||||
|
setRightPanelCollapsed(!bothCollapsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+1 / Alt+2 / Alt+3: 피드백 모드 전환
|
||||||
|
if (isAlt && e.key === "1") {
|
||||||
|
e.preventDefault();
|
||||||
|
setFeedbackMode("immersive");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAlt && e.key === "2") {
|
||||||
|
e.preventDefault();
|
||||||
|
setFeedbackMode("ambient");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAlt && e.key === "3") {
|
||||||
|
e.preventDefault();
|
||||||
|
setFeedbackMode("coached");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+Q: 회기 종료 확인
|
||||||
|
if (isAlt && (e.key.toLowerCase() === "q" || e.key.toLowerCase() === "w")) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!sessionEnded) setEndDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+J 또는 Alt+Down: 최신 발화로 점프
|
||||||
|
if (isAlt && (e.key.toLowerCase() === "j" || e.key === "ArrowDown")) {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpToLatest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+C: AI 코칭 이력
|
||||||
|
if (isAlt && e.key.toLowerCase() === "c") {
|
||||||
|
e.preventDefault();
|
||||||
|
openCoachHistory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+E: AI 코칭 근거
|
||||||
|
if (isAlt && e.key.toLowerCase() === "e") {
|
||||||
|
e.preventDefault();
|
||||||
|
setCoachEvidenceOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+P: 일시정지 토글
|
||||||
|
if (isAlt && e.key.toLowerCase() === "p") {
|
||||||
|
e.preventDefault();
|
||||||
togglePause();
|
togglePause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 입력창 밖 단축키
|
||||||
|
if (!isInput) {
|
||||||
|
if (e.key === "p" || e.key === "P" || e.key === " ") {
|
||||||
|
if (e.key === " ") e.preventDefault();
|
||||||
|
togglePause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [started, toggleMic, togglePause]);
|
}, [
|
||||||
|
started,
|
||||||
|
selectedGoals.length,
|
||||||
|
starting,
|
||||||
|
handleStart,
|
||||||
|
toggleMic,
|
||||||
|
togglePause,
|
||||||
|
leftPanelCollapsed,
|
||||||
|
rightPanelCollapsed,
|
||||||
|
sessionEnded,
|
||||||
|
jumpToLatest,
|
||||||
|
openCoachHistory,
|
||||||
|
shortcutHelpOpen,
|
||||||
|
coachEvidenceOpen,
|
||||||
|
coachHistoryOpen,
|
||||||
|
endDialogOpen,
|
||||||
|
ending,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
voiceCaptureMountedRef.current = true;
|
voiceCaptureMountedRef.current = true;
|
||||||
|
|
@ -2544,7 +2865,8 @@ export default function Session() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{started ? (
|
{started ? (
|
||||||
<nav className="sx-sessionbar" aria-label="세션 이동">
|
<nav className="sx-sessionbar" aria-label="세션 이동 및 패널 제어">
|
||||||
|
<div className="sx-sessionbar__left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sx-sessionbar__back"
|
className="sx-sessionbar__back"
|
||||||
|
|
@ -2567,6 +2889,33 @@ export default function Session() {
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-sessionbar__toggles">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"sx-sessionbar__toggle-btn" + (leftPanelCollapsed ? " is-collapsed" : "")}
|
||||||
|
onClick={() => setLeftPanelCollapsed((prev) => !prev)}
|
||||||
|
title={leftPanelCollapsed ? "회기 진행 및 컨텍스트 패널 펼치기" : "회기 진행 및 컨텍스트 패널 접기"}
|
||||||
|
aria-label={leftPanelCollapsed ? "회기 진행 및 컨텍스트 패널 펼치기" : "회기 진행 및 컨텍스트 패널 접기"}
|
||||||
|
aria-pressed={!leftPanelCollapsed}
|
||||||
|
>
|
||||||
|
<Icon name="session" size={15} />
|
||||||
|
<span>{leftPanelCollapsed ? "진행 열기" : "진행 접기"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"sx-sessionbar__toggle-btn" + (rightPanelCollapsed || feedbackMode === "immersive" ? " is-collapsed" : "")}
|
||||||
|
onClick={() => setRightPanelCollapsed((prev) => !prev)}
|
||||||
|
title={rightPanelCollapsed ? "AI 코칭 패널 펼치기" : "AI 코칭 패널 접기"}
|
||||||
|
aria-label={rightPanelCollapsed ? "AI 코칭 패널 펼치기" : "AI 코칭 패널 접기"}
|
||||||
|
aria-pressed={!rightPanelCollapsed && feedbackMode !== "immersive"}
|
||||||
|
>
|
||||||
|
<Icon name="spark" size={15} />
|
||||||
|
<span>{rightPanelCollapsed || feedbackMode === "immersive" ? "코칭 열기" : "코칭 접기"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="sx-sessionbar__actions">
|
<div className="sx-sessionbar__actions">
|
||||||
<button type="button" onClick={() => navigate("/learn")}>
|
<button type="button" onClick={() => navigate("/learn")}>
|
||||||
<Icon name="home" size={15} />
|
<Icon name="home" size={15} />
|
||||||
|
|
@ -2877,8 +3226,15 @@ export default function Session() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ── 3-region 그리드 ── */}
|
{/* ── 3-region 가변 그리드 ── */}
|
||||||
<div className="sx-grid">
|
<div
|
||||||
|
className={
|
||||||
|
"sx-grid" +
|
||||||
|
(leftPanelCollapsed ? " is-left-collapsed" : "") +
|
||||||
|
(rightPanelCollapsed || feedbackMode === "immersive" ? " is-right-collapsed" : "") +
|
||||||
|
(leftPanelCollapsed && (rightPanelCollapsed || feedbackMode === "immersive") ? " is-focus-mode" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
<section className={surfaceClassName("sx-mobile-context", { variant: "inset" })} aria-label="현재 회기 요약">
|
<section className={surfaceClassName("sx-mobile-context", { variant: "inset" })} aria-label="현재 회기 요약">
|
||||||
<div className="sx-mobile-context__row">
|
<div className="sx-mobile-context__row">
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -2945,11 +3301,38 @@ export default function Session() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── LEFT ── */}
|
{/* ── LEFT ── */}
|
||||||
|
{leftPanelCollapsed ? (
|
||||||
|
<aside className="sx-col-mini-rail sx-col-mini-rail--left" aria-label="회기 진행 및 컨텍스트 접힘">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-mini-rail-btn"
|
||||||
|
onClick={() => setLeftPanelCollapsed(false)}
|
||||||
|
title="회기 진행 및 컨텍스트 패널 펼치기"
|
||||||
|
aria-label="회기 진행 및 컨텍스트 패널 펼치기"
|
||||||
|
>
|
||||||
|
<Icon name="session" size={16} />
|
||||||
|
<span className="sx-mini-rail-txt">진행 · {stage}</span>
|
||||||
|
<Icon name="chevron-right" size={14} />
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
) : (
|
||||||
<div className="sx-col sx-col-left">
|
<div className="sx-col sx-col-left">
|
||||||
{/* 회기 단계 세로 트랙 */}
|
{/* 회기 단계 세로 트랙 */}
|
||||||
<section className={surfaceClassName("sx-panel sx-track")}>
|
<section className={surfaceClassName("sx-panel sx-track")}>
|
||||||
<div className="sx-track__head">
|
<div className="sx-track__head">
|
||||||
|
<div className="sx-panel-head-row">
|
||||||
<Kicker>회기 진행</Kicker>
|
<Kicker>회기 진행</Kicker>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-panel-close-btn"
|
||||||
|
onClick={() => setLeftPanelCollapsed(true)}
|
||||||
|
title="좌측 패널 접기"
|
||||||
|
aria-label="좌측 패널 접기"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" size={14} />
|
||||||
|
<span>접기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{SESSION_PHASES.map((p, i) => {
|
{SESSION_PHASES.map((p, i) => {
|
||||||
const cls =
|
const cls =
|
||||||
|
|
@ -3082,6 +3465,7 @@ export default function Session() {
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── CENTER ── */}
|
{/* ── CENTER ── */}
|
||||||
<div className="sx-col sx-col-center">
|
<div className="sx-col sx-col-center">
|
||||||
|
|
@ -3310,24 +3694,272 @@ export default function Session() {
|
||||||
aria-label="학습자 발화 입력"
|
aria-label="학습자 발화 입력"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sx-compose__actions">
|
||||||
|
<div className="sx-coach-anchor" ref={coachBubbleAnchorRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`sx-coach-trigger-btn ${isCoachBubbleOpen ? "is-active" : ""}`}
|
||||||
|
onClick={() => setIsCoachBubbleOpen((prev) => !prev)}
|
||||||
|
disabled={sessionEnded || paused}
|
||||||
|
aria-label={`AI 코칭, 남은 기회 ${coachQuotaRemaining}개`}
|
||||||
|
title="AI 코칭 받기"
|
||||||
|
>
|
||||||
|
<Icon name="spark" size={15} />
|
||||||
|
<span>AI 코칭</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"sx-coach-trigger-btn__badge" +
|
||||||
|
(coachQuotaRemaining <= 0 ? " is-empty" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{coachQuotaRemaining}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCoachBubbleOpen ? (
|
||||||
|
<div
|
||||||
|
className="sx-coach-bubble-popup"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="AI 슈퍼바이저 코칭 버블"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="sx-coach-bubble-popup__head">
|
||||||
|
<div className="sx-coach-bubble-popup__title">
|
||||||
|
<Icon name="spark" size={14} />
|
||||||
|
<strong>AI 슈퍼바이저 코칭</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sx-coach-bubble-popup__head-right">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"sx-coach-bubble-popup__quota" +
|
||||||
|
(coachQuotaRemaining <= 0 ? " is-empty" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
기회 {coachQuotaRemaining}/{coachQuotaMax}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-coach-bubble-popup__close"
|
||||||
|
onClick={() => setIsCoachBubbleOpen(false)}
|
||||||
|
aria-label="코칭 팝업 닫기"
|
||||||
|
>
|
||||||
|
<Icon name="x" size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="sx-coach-bubble-popup__body"
|
||||||
|
ref={coachChatScrollRef}
|
||||||
|
>
|
||||||
|
<div className="sx-coach-msg sx-coach-msg--welcome">
|
||||||
|
<div className="sx-coach-msg__avatar">
|
||||||
|
<Icon name="spark" size={13} />
|
||||||
|
</div>
|
||||||
|
<div className="sx-coach-msg__content">
|
||||||
|
<p>
|
||||||
|
상담 흐름이나 다음 발화에 대해 질문하시면 즉시 맞춤 조언을 드립니다.
|
||||||
|
</p>
|
||||||
|
{coachQuotaRemaining > 0 && coachChatMessages.length === 0 ? (
|
||||||
|
<div className="sx-coach-bubble-popup__chips">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-coach-chip"
|
||||||
|
onClick={() => void handleSendCoachBubbleQuery("다음 추천 발화와 방향을 알려줘")}
|
||||||
|
disabled={coachChatLoading}
|
||||||
|
>
|
||||||
|
💡 다음 추천 발화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-coach-chip"
|
||||||
|
onClick={() => void handleSendCoachBubbleQuery("내담자 저항을 낮추는 공감 기법을 알려줘")}
|
||||||
|
disabled={coachChatLoading}
|
||||||
|
>
|
||||||
|
🤝 저항 완화 공감법
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-coach-chip"
|
||||||
|
onClick={() => void handleSendCoachBubbleQuery("호소 문제를 더 깊이 탐색할 수 있는 질문을 제안해줘")}
|
||||||
|
disabled={coachChatLoading}
|
||||||
|
>
|
||||||
|
🔍 호소 문제 탐색 질문
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{coachChatMessages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`sx-coach-msg sx-coach-msg--${msg.sender}`}
|
||||||
|
>
|
||||||
|
{msg.sender === "coach" ? (
|
||||||
|
<div className="sx-coach-msg__avatar">
|
||||||
|
<Icon name="spark" size={13} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="sx-coach-msg__content">
|
||||||
|
{msg.suggestion ? (
|
||||||
|
<div className="sx-coach-msg__suggestion">
|
||||||
|
<strong className="sx-coach-msg__title">
|
||||||
|
{msg.suggestion.title}
|
||||||
|
</strong>
|
||||||
|
<p className="sx-coach-msg__desc">
|
||||||
|
{msg.suggestion.message}
|
||||||
|
</p>
|
||||||
|
{msg.suggestion.next_utterance ? (
|
||||||
|
<div className="sx-coach-msg__next-utterance">
|
||||||
|
<span className="sx-coach-msg__next-label">
|
||||||
|
추천 발화
|
||||||
|
</span>
|
||||||
|
<blockquote>{msg.suggestion.next_utterance}</blockquote>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-coach-msg__apply-btn"
|
||||||
|
onClick={() =>
|
||||||
|
applySuggestionToComposer(
|
||||||
|
msg.suggestion!.next_utterance!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title="이 문장을 발화 입력창에 넣기"
|
||||||
|
>
|
||||||
|
<Icon name="copy" size={12} />
|
||||||
|
<span>발화창에 넣기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{msg.suggestion.rationale ? (
|
||||||
|
<small className="sx-coach-msg__rationale">
|
||||||
|
{msg.suggestion.rationale}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>{msg.text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{coachChatLoading ? (
|
||||||
|
<div className="sx-coach-msg sx-coach-msg--coach sx-coach-msg--loading">
|
||||||
|
<div className="sx-coach-msg__avatar is-pulsing">
|
||||||
|
<Icon name="spark" size={13} />
|
||||||
|
</div>
|
||||||
|
<div className="sx-coach-msg__content">
|
||||||
|
<div className="sx-coach-typing-indicator">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<small>AI 슈퍼바이저가 조언을 구성하고 있습니다...</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-coach-bubble-popup__footer">
|
||||||
|
{coachQuotaRemaining <= 0 ? (
|
||||||
|
<div className="sx-coach-bubble-popup__exhausted">
|
||||||
|
코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리면 충전됩니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="sx-coach-bubble-popup__composer"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!coachChatInput.trim() || coachChatLoading) return;
|
||||||
|
const text = coachChatInput.trim();
|
||||||
|
setCoachChatInput("");
|
||||||
|
void handleSendCoachBubbleQuery(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={coachChatInputRef}
|
||||||
|
type="text"
|
||||||
|
value={coachChatInput}
|
||||||
|
onChange={(e) => setCoachChatInput(e.target.value)}
|
||||||
|
placeholder="코치에게 조언을 구해보세요... (Enter 전송)"
|
||||||
|
disabled={coachChatLoading || sessionEnded || paused}
|
||||||
|
aria-label="AI 코칭 질문 입력"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
!coachChatInput.trim() ||
|
||||||
|
coachChatLoading ||
|
||||||
|
sessionEnded ||
|
||||||
|
paused
|
||||||
|
}
|
||||||
|
aria-label="코칭 질문 전송"
|
||||||
|
title="질문 전송"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-up" size={13} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void handleSend()}
|
onClick={() => void handleSend()}
|
||||||
disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded || alliancePreGateBlocked}
|
disabled={
|
||||||
|
!composeText.trim() ||
|
||||||
|
textTurnBlocked ||
|
||||||
|
paused ||
|
||||||
|
sessionEnded ||
|
||||||
|
alliancePreGateBlocked
|
||||||
|
}
|
||||||
trailing={<Icon name="arrow-up" size={15} />}
|
trailing={<Icon name="arrow-up" size={15} />}
|
||||||
>
|
>
|
||||||
보내기
|
보내기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── RIGHT ── */}
|
{/* ── RIGHT ── */}
|
||||||
|
{rightPanelCollapsed ? (
|
||||||
|
<aside className="sx-col-mini-rail sx-col-mini-rail--right" aria-label="AI 코칭 및 신호 패널 접힘">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-mini-rail-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setRightPanelCollapsed(false);
|
||||||
|
}}
|
||||||
|
title="AI 코칭 및 신호 패널 펼치기"
|
||||||
|
aria-label="AI 코칭 및 신호 패널 펼치기"
|
||||||
|
>
|
||||||
|
<Icon name="spark" size={16} />
|
||||||
|
<span className="sx-mini-rail-txt">AI 코치 · 신호</span>
|
||||||
|
<Icon name="chevron-left" size={14} />
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
) : (
|
||||||
<div className="sx-col sx-col-right">
|
<div className="sx-col sx-col-right">
|
||||||
{/* 라이브 신호 (몰입 모드면 숨김) */}
|
|
||||||
{feedbackMode !== "immersive" ? (
|
{feedbackMode !== "immersive" ? (
|
||||||
<section className={surfaceClassName("sx-panel sx-signal")}>
|
<section className={surfaceClassName("sx-panel sx-signal")}>
|
||||||
<div className="sx-signal__head">
|
<div className="sx-signal__head">
|
||||||
|
<div className="sx-panel-head-row">
|
||||||
<Kicker dot={false}>라이브 코칭</Kicker>
|
<Kicker dot={false}>라이브 코칭</Kicker>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sx-panel-close-btn"
|
||||||
|
onClick={() => setRightPanelCollapsed(true)}
|
||||||
|
title="AI 코칭 패널 접기"
|
||||||
|
aria-label="AI 코칭 패널 접기"
|
||||||
|
>
|
||||||
|
<span>접기</span>
|
||||||
|
<Icon name="chevron-right" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{liveSignal ? (
|
{liveSignal ? (
|
||||||
|
|
@ -3500,7 +4132,6 @@ export default function Session() {
|
||||||
) : (
|
) : (
|
||||||
<section className={surfaceClassName("sx-panel sx-signal")}>
|
<section className={surfaceClassName("sx-panel sx-signal")}>
|
||||||
<div className="sx-signal__head">
|
<div className="sx-signal__head">
|
||||||
{/* D1 — 섹션 라벨 → dot 제거 */}
|
|
||||||
<Kicker dot={false}>몰입 모드</Kicker>
|
<Kicker dot={false}>몰입 모드</Kicker>
|
||||||
</div>
|
</div>
|
||||||
<div className="sx-signal__defer">
|
<div className="sx-signal__defer">
|
||||||
|
|
@ -3634,6 +4265,7 @@ export default function Session() {
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 하단 컨트롤 바 ── */}
|
{/* ── 하단 컨트롤 바 ── */}
|
||||||
|
|
@ -4121,6 +4753,125 @@ export default function Session() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{shortcutHelpOpen ? (
|
||||||
|
<div
|
||||||
|
className="sx-coach-modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) setShortcutHelpOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
className="sx-coach-modal sx-shortcut-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="sx-shortcut-modal-title"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="sx-coach-modal__head">
|
||||||
|
<span className="sx-coach-modal__avatar" aria-hidden="true">
|
||||||
|
<Icon name="spark" size={20} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 id="sx-shortcut-modal-title">키보드 단축키 안내</h2>
|
||||||
|
<p>마우스 없이도 상담과 모든 인터랙션을 빠르게 조작할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-coach-modal__body sx-shortcut-modal__body">
|
||||||
|
<div className="sx-shortcut-group">
|
||||||
|
<h3>상담 및 음성</h3>
|
||||||
|
<dl className="sx-shortcut-list">
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>M</kbd></dt>
|
||||||
|
<dd>마이크 켜기/끄기 토글</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>P</kbd> 또는 <kbd>Space</kbd></dt>
|
||||||
|
<dd>회기 일시정지 / 재개</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Enter</kbd></dt>
|
||||||
|
<dd>텍스트 발화 즉시 전송</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>J</kbd> 또는 <kbd>Alt</kbd>+<kbd>↓</kbd></dt>
|
||||||
|
<dd>실시간 자막 최신 발화로 스크롤</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-shortcut-group">
|
||||||
|
<h3>레이아웃 & 패널 접기</h3>
|
||||||
|
<dl className="sx-shortcut-list">
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>[</kbd> 또는 <kbd>Ctrl</kbd>+<kbd>[</kbd></dt>
|
||||||
|
<dd>좌측 (진행 & 컨텍스트) 패널 접기/펼치기</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>]</kbd> 또는 <kbd>Ctrl</kbd>+<kbd>]</kbd></dt>
|
||||||
|
<dd>우측 (AI 코치 & 신호) 패널 접기/펼치기</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>\</kbd> 또는 <kbd>Ctrl</kbd>+<kbd>\</kbd></dt>
|
||||||
|
<dd>완전 몰입 모드 (양쪽 패널 동시 토글)</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>1</kbd> / <kbd>2</kbd> / <kbd>3</kbd></dt>
|
||||||
|
<dd>몰입 / 상태 신호 / 코칭 모드 즉시 전환</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-shortcut-group">
|
||||||
|
<h3>AI 코치 및 분석</h3>
|
||||||
|
<dl className="sx-shortcut-list">
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>C</kbd></dt>
|
||||||
|
<dd>AI 코칭 이력 열기</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>E</kbd></dt>
|
||||||
|
<dd>방금 발화 코칭 근거 열기</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-shortcut-group">
|
||||||
|
<h3>세션 관리 & 내비게이션</h3>
|
||||||
|
<dl className="sx-shortcut-list">
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Alt</kbd>+<kbd>Q</kbd> 또는 <kbd>Alt</kbd>+<kbd>W</kbd></dt>
|
||||||
|
<dd>회기 종료 확인창 열기</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Ctrl</kbd>+<kbd>Enter</kbd></dt>
|
||||||
|
<dd>시작 전 화면에서 회기 즉시 시작</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>?</kbd> 또는 <kbd>Ctrl</kbd>+<kbd>/</kbd></dt>
|
||||||
|
<dd>단축키 도움말 열기/닫기</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Escape</kbd> 또는 <kbd>Ctrl</kbd>+<kbd>W</kbd></dt>
|
||||||
|
<dd>열려있는 모달/팝업 닫기</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-coach-modal__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShortcutHelpOpen(false)}
|
||||||
|
>
|
||||||
|
확인 (Esc)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
|
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
|
||||||
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
|
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
|
||||||
{timeUp && !sessionEnded ? "회기 시간 만료" : `경과 ${formatElapsed(elapsed)}`}
|
{timeUp && !sessionEnded ? "회기 시간 만료" : `경과 ${formatElapsed(elapsed)}`}
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card {
|
.axl__card {
|
||||||
--tone: #53616f;
|
--tone: var(--neutral-500);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 188px;
|
min-height: 188px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -163,23 +163,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card--positive {
|
.axl__card--positive {
|
||||||
--tone: #2f7d57;
|
--tone: var(--pos-solid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card--negative {
|
.axl__card--negative {
|
||||||
--tone: #a84444;
|
--tone: var(--crit-solid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card--defensive {
|
.axl__card--defensive {
|
||||||
--tone: #7a5730;
|
--tone: var(--clay);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card--cognitive {
|
.axl__card--cognitive {
|
||||||
--tone: #386f9d;
|
--tone: var(--info-solid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card--energy {
|
.axl__card--energy {
|
||||||
--tone: #7565a8;
|
--tone: var(--accent-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card .vg-avatar {
|
.axl__card .vg-avatar {
|
||||||
|
|
@ -187,7 +187,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card .vg-avatar__stage {
|
.axl__card .vg-avatar__stage {
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bg-surface) 5%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axl__card-meta {
|
.axl__card-meta {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
|
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
|
||||||
.ap__head h1{font-size:var(--fs-h1);margin:0 0 6px;}
|
.ap__head h1{font-size:var(--fs-h1);margin:0 0 6px;}
|
||||||
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
|
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
|
||||||
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
|
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,color-mix(in srgb,var(--accent-tint) 96%,transparent),color-mix(in srgb,var(--bg-surface-2) 92%,transparent));margin-bottom:32px;}
|
||||||
.ap__hero .vg-avatar__persona{color:#1c2a2a;}
|
.ap__hero .vg-avatar__persona{color:var(--text-strong);}
|
||||||
.ap__hero .vg-avatar__state{color:#5a6663;}
|
.ap__hero .vg-avatar__state{color:var(--text-muted);}
|
||||||
.ap__hero .vg-avatar__state b{color:#1c2a2a;}
|
.ap__hero .vg-avatar__state b{color:var(--text-strong);}
|
||||||
.ap__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;}
|
.ap__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;}
|
||||||
.ap__cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:8px;}
|
.ap__cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:8px;}
|
||||||
.ap__cell figcaption{font-size:var(--fs-xs);color:var(--text-muted);}
|
.ap__cell figcaption{font-size:var(--fs-xs);color:var(--text-muted);}
|
||||||
|
|
|
||||||
|
|
@ -75,48 +75,35 @@
|
||||||
min-width:0;
|
min-width:0;
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||||
gap:10px;
|
gap:12px;
|
||||||
}
|
}
|
||||||
/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 숨김(리뷰 화면 sr-tabs와 동일 패턴) */
|
/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 숨김(리뷰 화면 sr-tabs와 동일 패턴) */
|
||||||
.lh-tabs{
|
.lh-tabs{
|
||||||
display:none;
|
display:none;
|
||||||
gap:6px;
|
gap:4px;
|
||||||
padding:4px;
|
padding:4px;
|
||||||
border:1px solid var(--border-subtle);
|
border:1px solid var(--border-subtle);
|
||||||
border-radius:var(--radius);
|
border-radius:var(--radius-pill);
|
||||||
background:var(--bg-surface-2);
|
background:var(--bg-surface-2);
|
||||||
|
box-shadow:var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.lh-tabs button{
|
.lh-tabs button{
|
||||||
position:relative;
|
position:relative;
|
||||||
flex:1 1 0;
|
flex:1 1 0;
|
||||||
min-width:0;
|
min-width:0;
|
||||||
padding:9px 12px;
|
padding:8px 12px;
|
||||||
border:1px solid transparent;
|
border:1px solid transparent;
|
||||||
border-radius:calc(var(--radius) - 2px);
|
border-radius:var(--radius-pill);
|
||||||
background:transparent;
|
background:transparent;
|
||||||
color:var(--text-body);
|
color:var(--text-body);
|
||||||
font-size:var(--fs-sm);
|
font-size:var(--fs-sm);
|
||||||
font-weight:700;
|
font-weight:650;
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
min-height:44px;
|
min-height:42px;
|
||||||
|
transition:all var(--dur-fast) var(--ease-spring);
|
||||||
}
|
}
|
||||||
/* D6(탭 구분 강화): 비활성 탭이 배경과 같아 "여러 개 중 하나"로 안 읽혔다.
|
.lh-tabs button:active{
|
||||||
비활성 탭 사이에만 세로 구분선을 넣는다. 활성 탭은 자체 배경/테두리로
|
transform:scale(0.96);
|
||||||
이미 구분되므로 활성 탭에 붙는 구분선은 숨겨 이중 경계선을 막는다. */
|
|
||||||
.lh-tabs button + button::before{
|
|
||||||
content:"";
|
|
||||||
position:absolute;
|
|
||||||
left:-4px;
|
|
||||||
top:22%;
|
|
||||||
bottom:22%;
|
|
||||||
width:1px;
|
|
||||||
background:var(--border-strong);
|
|
||||||
opacity:.55;
|
|
||||||
pointer-events:none;
|
|
||||||
}
|
|
||||||
.lh-tabs button.is-active::before,
|
|
||||||
.lh-tabs button.is-active + button::before{
|
|
||||||
opacity:0;
|
|
||||||
}
|
}
|
||||||
.lh-tabs button:hover{
|
.lh-tabs button:hover{
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
|
|
@ -131,6 +118,7 @@
|
||||||
border-color:var(--border-strong);
|
border-color:var(--border-strong);
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
box-shadow:var(--shadow-sm);
|
box-shadow:var(--shadow-sm);
|
||||||
|
font-weight:750;
|
||||||
}
|
}
|
||||||
.lh-dashboard-side{
|
.lh-dashboard-side{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
|
|
@ -448,7 +436,7 @@
|
||||||
inset:0 auto 0 0;
|
inset:0 auto 0 0;
|
||||||
width:var(--lh-stage-progress,0%);
|
width:var(--lh-stage-progress,0%);
|
||||||
border-radius:inherit;
|
border-radius:inherit;
|
||||||
background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 72%,#f2c36b));
|
background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 72%,var(--warn-solid)));
|
||||||
}
|
}
|
||||||
.lh-recap__quote{
|
.lh-recap__quote{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
|
|
@ -1798,19 +1786,19 @@
|
||||||
[data-theme="dark"] .lh-compact-list li,
|
[data-theme="dark"] .lh-compact-list li,
|
||||||
[data-theme="dark"] .lh-persona,
|
[data-theme="dark"] .lh-persona,
|
||||||
[data-theme="dark"] .lh-persona-progress__row{
|
[data-theme="dark"] .lh-persona-progress__row{
|
||||||
border-color:rgba(203,227,220,.11);
|
border-color:color-mix(in srgb, var(--accent) 11%, transparent);
|
||||||
background:rgba(255,255,255,.045);
|
background:color-mix(in srgb, var(--text-strong) 4.5%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .lh-recommend-card{
|
[data-theme="dark"] .lh-recommend-card{
|
||||||
border-color:rgba(111,179,164,.22);
|
border-color:color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg,rgba(111,179,164,.12),transparent 62%),
|
linear-gradient(135deg,color-mix(in srgb, var(--accent) 12%, transparent),transparent 62%),
|
||||||
rgba(255,255,255,.052);
|
color-mix(in srgb, var(--text-strong) 5.2%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .lh-persona.is-selected,
|
[data-theme="dark"] .lh-persona.is-selected,
|
||||||
[data-theme="dark"] .lh-persona-progress__row.is-selected{
|
[data-theme="dark"] .lh-persona-progress__row.is-selected{
|
||||||
border-color:rgba(111,179,164,.66);
|
border-color:color-mix(in srgb, var(--accent) 66%, transparent);
|
||||||
background:rgba(111,179,164,.13);
|
background:color-mix(in srgb, var(--accent) 13%, transparent);
|
||||||
}
|
}
|
||||||
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
|
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
|
||||||
/* 중간 폭(1181~1420px): 우측 사이드는 유지하되 리캡+코치 2열은 세로 스택으로.
|
/* 중간 폭(1181~1420px): 우측 사이드는 유지하되 리캡+코치 2열은 세로 스택으로.
|
||||||
|
|
@ -1939,9 +1927,14 @@
|
||||||
.lh-recap__facts{
|
.lh-recap__facts{
|
||||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
}
|
}
|
||||||
|
.lh-dashboard-status{
|
||||||
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
|
gap:10px;
|
||||||
|
}
|
||||||
.lh-metric-card{
|
.lh-metric-card{
|
||||||
min-height:0;
|
min-height:0;
|
||||||
padding:12px;
|
padding:14px;
|
||||||
|
border-radius:var(--radius);
|
||||||
}
|
}
|
||||||
.lh-personas{
|
.lh-personas{
|
||||||
display:flex;
|
display:flex;
|
||||||
|
|
@ -2175,7 +2168,7 @@
|
||||||
/* ═══ 학습 대시보드 보태니컬 시안 적용 ═══
|
/* ═══ 학습 대시보드 보태니컬 시안 적용 ═══
|
||||||
카드 광원은 좌상단, 황금 기운은 헤어라인과 가장자리에서만 아주 옅게 사용한다. */
|
카드 광원은 좌상단, 황금 기운은 헤어라인과 가장자리에서만 아주 옅게 사용한다. */
|
||||||
.vg-shell--learner-dashboard .lh-root {
|
.vg-shell--learner-dashboard .lh-root {
|
||||||
--lh-gold: #b5975d;
|
--lh-gold: var(--clay-deep);
|
||||||
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 34%, var(--border-subtle));
|
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 34%, var(--border-subtle));
|
||||||
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 17%, var(--border-subtle));
|
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 17%, var(--border-subtle));
|
||||||
gap: clamp(18px, 2vw, 28px);
|
gap: clamp(18px, 2vw, 28px);
|
||||||
|
|
@ -2187,7 +2180,7 @@
|
||||||
.vg-shell--learner-dashboard .lh-head__status {
|
.vg-shell--learner-dashboard .lh-head__status {
|
||||||
border-color: var(--lh-botanical-hair);
|
border-color: var(--lh-botanical-hair);
|
||||||
background: var(--glass-surface-inset);
|
background: var(--glass-surface-inset);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 62%, transparent);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
.vg-shell--learner-dashboard .lh-dashboard-status {
|
.vg-shell--learner-dashboard .lh-dashboard-status {
|
||||||
|
|
@ -2261,7 +2254,7 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: radial-gradient(circle at 100% 0%, rgba(187, 156, 97, 0.06), transparent 22rem);
|
background: radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--clay) 6%, transparent), transparent 22rem);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.vg-shell--learner-dashboard .lh-work-cluster > * {
|
.vg-shell--learner-dashboard .lh-work-cluster > * {
|
||||||
|
|
@ -2298,7 +2291,7 @@
|
||||||
background:var(--accent);
|
background:var(--accent);
|
||||||
border-color:color-mix(in srgb,var(--accent-deep) 78%,var(--lh-gold));
|
border-color:color-mix(in srgb,var(--accent-deep) 78%,var(--lh-gold));
|
||||||
color:var(--text-on-accent);
|
color:var(--text-on-accent);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 28%, transparent);
|
||||||
}
|
}
|
||||||
.vg-shell--learner-dashboard .lh-coach-card__cta:hover:not(:disabled) {
|
.vg-shell--learner-dashboard .lh-coach-card__cta:hover:not(:disabled) {
|
||||||
background:var(--accent-deep);
|
background:var(--accent-deep);
|
||||||
|
|
@ -2311,12 +2304,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
|
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
|
||||||
--lh-gold: #bca579;
|
--lh-gold: var(--clay);
|
||||||
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 27%, var(--border-subtle));
|
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 27%, var(--border-subtle));
|
||||||
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 16%, var(--border-subtle));
|
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 16%, var(--border-subtle));
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .vg-shell--learner-dashboard .lh-head__status {
|
[data-theme="dark"] .vg-shell--learner-dashboard .lh-head__status {
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 245, 222, 0.055);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--clay-tint) 5.5%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card {
|
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card {
|
||||||
--lh-card-art: url("/design-elements/learner-dashboard/card-leaf-sprig.png");
|
--lh-card-art: url("/design-elements/learner-dashboard/card-leaf-sprig.png");
|
||||||
|
|
@ -2336,8 +2329,8 @@
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .vg-shell--learner-dashboard .lh-work-cluster::before {
|
[data-theme="dark"] .vg-shell--learner-dashboard .lh-work-cluster::before {
|
||||||
background:
|
background:
|
||||||
linear-gradient(105deg, rgba(203, 184, 139, 0.025), transparent 38%),
|
linear-gradient(105deg, color-mix(in srgb, var(--clay) 2.5%, transparent), transparent 38%),
|
||||||
radial-gradient(circle at 100% 0%, rgba(187, 156, 97, 0.055), transparent 22rem);
|
radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--clay) 5.5%, transparent), transparent 22rem);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .vg-shell--learner-dashboard .lh-coach-card::after {
|
[data-theme="dark"] .vg-shell--learner-dashboard .lh-coach-card::after {
|
||||||
background-image: url("/design-elements/learner-dashboard/card-paired-leaves.png");
|
background-image: url("/design-elements/learner-dashboard/card-paired-leaves.png");
|
||||||
|
|
@ -2600,11 +2593,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
|
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
|
||||||
--text-strong: #eee8dc;
|
--text-strong: var(--ink-0);
|
||||||
--text-body: #c3beb2;
|
--text-body: var(--ink-2);
|
||||||
--text-muted: #928f86;
|
--text-muted: var(--ink-3);
|
||||||
--lh-botanical-hair: rgba(184, 160, 110, 0.31);
|
--lh-botanical-hair: color-mix(in srgb, var(--clay) 31%, transparent);
|
||||||
--lh-botanical-line: rgba(184, 160, 110, 0.22);
|
--lh-botanical-line: color-mix(in srgb, var(--clay) 22%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .vg-shell--learner-dashboard .lh-recap__avatar {
|
[data-theme="dark"] .vg-shell--learner-dashboard .lh-recap__avatar {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
background: linear-gradient(90deg, #6b9488, #9fc0b5);
|
background: linear-gradient(90deg, var(--accent-deep), var(--accent-bright));
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
|
|
@ -205,6 +205,11 @@
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg-obtn:active {
|
||||||
|
transform: translateY(1px) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg-obtn.primary {
|
.lg-obtn.primary {
|
||||||
|
|
@ -286,9 +291,8 @@
|
||||||
margin-top: var(--sp-3);
|
margin-top: var(--sp-3);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
/* 2026-07-27: 기존 rgba(154,94,20,.28) + #f2b75f 조합은 다크 패널 전제였다.
|
/* 2026-07-27: 기존 하드코딩 다크 전제 색상 대신
|
||||||
라이트 패널 위에서는 탄색 배경에 밝은 앰버 글자가 얹혀 1.19:1까지 떨어져
|
라이트/다크 테마별로 스왑되는 warn 의미 토큰으로 교체한다. */
|
||||||
사실상 읽을 수 없었다. 테마별로 스왑되는 warn 의미 토큰으로 교체한다. */
|
|
||||||
background: var(--warn-tint);
|
background: var(--warn-tint);
|
||||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
|
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
|
||||||
color: var(--warn-text);
|
color: var(--warn-text);
|
||||||
|
|
@ -335,6 +339,11 @@
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg-roleopt:active {
|
||||||
|
transform: translateY(1px) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg-roleopt.is-sel {
|
.lg-roleopt.is-sel {
|
||||||
|
|
@ -353,11 +362,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg-roleopt .d.teacher {
|
.lg-roleopt .d.teacher {
|
||||||
background: #5478c4;
|
background: var(--info-solid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg-roleopt .d.admin {
|
.lg-roleopt .d.admin {
|
||||||
background: #7d818e;
|
background: var(--neutral-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg-roleopt b {
|
.lg-roleopt b {
|
||||||
|
|
@ -376,14 +385,19 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
margin-top: var(--sp-3);
|
margin-top: var(--sp-3);
|
||||||
border: 1px solid rgba(114, 211, 197, 0.58);
|
border: 1px solid color-mix(in srgb, var(--accent) 58%, transparent);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: rgba(89, 181, 166, 0.15);
|
background: color-mix(in srgb, var(--accent-tint) 60%, transparent);
|
||||||
color: var(--accent-deep);
|
color: var(--accent-deep);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg-devbtn:active {
|
||||||
|
transform: translateY(1px) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg-devbtn:disabled {
|
.lg-devbtn:disabled {
|
||||||
|
|
@ -394,8 +408,8 @@
|
||||||
.lg-error {
|
.lg-error {
|
||||||
margin: var(--sp-3) 0 0;
|
margin: var(--sp-3) 0 0;
|
||||||
color: var(--crit-text);
|
color: var(--crit-text);
|
||||||
background: rgba(122, 38, 38, 0.28);
|
background: var(--crit-tint);
|
||||||
border: 1px solid rgba(255, 145, 145, 0.18);
|
border: 1px solid color-mix(in srgb, var(--crit-solid) 30%, transparent);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
|
||||||
|
|
@ -172,8 +172,14 @@
|
||||||
grid-template-columns:auto minmax(0,1fr);
|
grid-template-columns:auto minmax(0,1fr);
|
||||||
align-items:center;
|
align-items:center;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
min-height:28px;
|
min-height:34px;
|
||||||
|
padding:4px 6px;
|
||||||
|
border-radius:var(--radius-sm);
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
|
transition:background var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ob-checks label:hover{
|
||||||
|
background:color-mix(in srgb,var(--text-strong) 4%,transparent);
|
||||||
}
|
}
|
||||||
.ob-checks input{
|
.ob-checks input{
|
||||||
width:18px;
|
width:18px;
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@
|
||||||
padding:clamp(24px,5vw,44px);
|
padding:clamp(24px,5vw,44px);
|
||||||
border:1px solid var(--border-subtle);
|
border:1px solid var(--border-subtle);
|
||||||
border-radius:var(--radius);
|
border-radius:var(--radius);
|
||||||
background:color-mix(in srgb,var(--bg-surface) 92%,white 8%);
|
background: var(--bg-surface);
|
||||||
box-shadow:0 18px 50px rgba(28,43,40,.10);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
.pa-mark{
|
.pa-mark{
|
||||||
width:62px;
|
width:62px;
|
||||||
|
|
@ -63,8 +63,13 @@
|
||||||
width:10px;
|
width:10px;
|
||||||
height:10px;
|
height:10px;
|
||||||
border-radius:50%;
|
border-radius:50%;
|
||||||
background:#d89a2b;
|
background:var(--warn-solid);
|
||||||
box-shadow:0 0 0 5px rgba(216,154,43,.14);
|
box-shadow:0 0 0 5px var(--warn-tint);
|
||||||
|
animation:paBreathe 2400ms var(--ease-in-out) infinite;
|
||||||
|
}
|
||||||
|
@keyframes paBreathe{
|
||||||
|
0%,100%{transform:scale(1);opacity:0.85;}
|
||||||
|
50%{transform:scale(1.2);opacity:1;}
|
||||||
}
|
}
|
||||||
.pa-status b{
|
.pa-status b{
|
||||||
display:block;
|
display:block;
|
||||||
|
|
|
||||||
|
|
@ -907,8 +907,8 @@
|
||||||
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
|
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
|
||||||
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
|
||||||
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
.ps-point-list small{color:var(--text-muted);text-align:right;}
|
||||||
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden;background:color-mix(in srgb,var(--bg-surface) 78%,transparent);}
|
.ps-stepper button{min-width:0;display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:center;padding:13px 14px;border:0;border-right:1px solid var(--border-subtle);background:transparent;color:var(--text-muted);text-align:left;cursor:pointer;transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);}
|
||||||
.ps-stepper button{min-width:0;display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:center;padding:13px 14px;border:0;border-right:1px solid var(--border-subtle);background:transparent;color:var(--text-muted);text-align:left;cursor:pointer;}
|
.ps-stepper button:active{transform:scale(0.96);}
|
||||||
.ps-stepper button:last-child{border-right:0;}
|
.ps-stepper button:last-child{border-right:0;}
|
||||||
.ps-stepper button > span:first-child{display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--border-subtle);border-radius:50%;font-family:var(--font-num);font-size:11px;font-weight:800;}
|
.ps-stepper button > span:first-child{display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--border-subtle);border-radius:50%;font-family:var(--font-num);font-size:11px;font-weight:800;}
|
||||||
.ps-stepper button > span:last-child{min-width:0;display:grid;gap:2px;}
|
.ps-stepper button > span:last-child{min-width:0;display:grid;gap:2px;}
|
||||||
|
|
|
||||||
|
|
@ -1326,6 +1326,10 @@
|
||||||
.pf-session:first-child{border-top:0;}
|
.pf-session:first-child{border-top:0;}
|
||||||
.pf-session--action{
|
.pf-session--action{
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
|
transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.pf-session--action:active{
|
||||||
|
transform:scale(0.985);
|
||||||
}
|
}
|
||||||
.pf-session--action:hover{
|
.pf-session--action:hover{
|
||||||
background:var(--bg-surface-2);
|
background:var(--bg-surface-2);
|
||||||
|
|
|
||||||
|
|
@ -784,7 +784,7 @@
|
||||||
padding: 0 var(--sp-4);
|
padding: 0 var(--sp-4);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--text-on-accent, #fff);
|
color: var(--text-on-accent);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
font-weight: 720;
|
font-weight: 720;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@
|
||||||
.dp-runtime-observation__message.is-waiting {
|
.dp-runtime-observation__message.is-waiting {
|
||||||
border-color: color-mix(
|
border-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--botanical-gold, #96743d) 44%,
|
var(--botanical-gold, var(--clay)) 44%,
|
||||||
var(--border-subtle)
|
var(--border-subtle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +197,7 @@
|
||||||
.dp-runtime-observation__message.is-error {
|
.dp-runtime-observation__message.is-error {
|
||||||
border-color: color-mix(
|
border-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--danger, #a34a46) 42%,
|
var(--danger, var(--crit-solid)) 42%,
|
||||||
var(--border-subtle)
|
var(--border-subtle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -483,7 +483,7 @@
|
||||||
.dp-launch-ticket__cta {
|
.dp-launch-ticket__cta {
|
||||||
padding: 0 var(--sp-4);
|
padding: 0 var(--sp-4);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--text-on-accent, #fff);
|
color: var(--text-on-accent);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,7 +523,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-gate--transfer_pending > span {
|
.dp-gate--transfer_pending > span {
|
||||||
border-color: var(--botanical-gold, #96743d);
|
border-color: var(--botanical-gold, var(--clay));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-gate--mastered > span {
|
.dp-gate--mastered > span {
|
||||||
|
|
@ -865,8 +865,8 @@
|
||||||
|
|
||||||
.dp-form-message--error {
|
.dp-form-message--error {
|
||||||
border: 1px solid
|
border: 1px solid
|
||||||
color-mix(in srgb, var(--danger, #a34a46) 38%, var(--border-subtle));
|
color-mix(in srgb, var(--danger, var(--crit-solid)) 38%, var(--border-subtle));
|
||||||
color: var(--danger, #8d3834);
|
color: var(--danger, var(--crit-text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-form-message--success {
|
.dp-form-message--success {
|
||||||
|
|
@ -969,7 +969,7 @@
|
||||||
.dp-card--error {
|
.dp-card--error {
|
||||||
border-color: color-mix(
|
border-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--danger, #a34a46) 35%,
|
var(--danger, var(--crit-solid)) 35%,
|
||||||
var(--border-subtle)
|
var(--border-subtle)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,41 +300,34 @@
|
||||||
/* ── 리뷰 영역 가로 탭 ── */
|
/* ── 리뷰 영역 가로 탭 ── */
|
||||||
.sr-tabs {
|
.sr-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-pill);
|
||||||
background: var(--bg-surface-2);
|
background: var(--bg-surface-2);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.sr-tabs button {
|
.sr-tabs button {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 9px 12px;
|
padding: 8px 14px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: calc(var(--radius) - 2px);
|
border-radius: var(--radius-pill);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-body);
|
color: var(--text-body);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
font-weight: 700;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 44px;
|
min-height: 42px;
|
||||||
|
transition: all var(--dur-fast) var(--ease-spring);
|
||||||
}
|
}
|
||||||
/* D6: 비활성 탭이 배경과 붙어 보이지 않으므로 탭 사이마다 얇은 구분선을 세워
|
.sr-tabs button:active {
|
||||||
"여러 개 중 하나"임을 드러낸다. 가운데 탭이 활성일 때도 구분이 남도록 항상 그리고,
|
transform: scale(0.96);
|
||||||
활성 탭 카드와 겹치지 않게 6px 간격 한가운데(-4px)에 둔다. */
|
|
||||||
.sr-tabs button + button::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: -4px;
|
|
||||||
top: 24%;
|
|
||||||
bottom: 24%;
|
|
||||||
width: 1px;
|
|
||||||
background: var(--border-strong);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
.sr-tabs button:hover {
|
.sr-tabs button:hover {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
background: color-mix(in srgb, var(--text-strong) 5%, transparent);
|
||||||
}
|
}
|
||||||
.sr-tabs button:focus-visible {
|
.sr-tabs button:focus-visible {
|
||||||
outline: 2px solid var(--focus-ring);
|
outline: 2px solid var(--focus-ring);
|
||||||
|
|
@ -345,6 +338,7 @@
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 대기 자리표시자는 사이드 레일에서 컴팩트하게 — 큰 빈 박스 금지 */
|
/* 대기 자리표시자는 사이드 레일에서 컴팩트하게 — 큰 빈 박스 금지 */
|
||||||
|
|
@ -485,6 +479,8 @@
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
text-wrap: balance;
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
.sr-overview .sr-summary {
|
.sr-overview .sr-summary {
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
|
|
@ -1517,24 +1513,24 @@
|
||||||
[data-theme="dark"] .sr-card,
|
[data-theme="dark"] .sr-card,
|
||||||
[data-theme="dark"] .sr-overview {
|
[data-theme="dark"] .sr-overview {
|
||||||
background: var(--surface-gradient);
|
background: var(--surface-gradient);
|
||||||
border-color: rgba(203, 227, 220, 0.13);
|
border-color: color-mix(in srgb, var(--accent) 13%, var(--border-subtle));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 16px 42px rgba(3, 9, 8, 0.24),
|
var(--shadow-md),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 3.5%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-card--transcript {
|
[data-theme="dark"] .sr-card--transcript {
|
||||||
background: linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-app) 98%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-overview {
|
[data-theme="dark"] .sr-overview {
|
||||||
border-color: rgba(125, 162, 148, 0.2);
|
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
background: linear-gradient(180deg, rgba(32, 50, 45, 0.94), rgba(18, 31, 28, 0.98));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 94%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-card--worksheet {
|
[data-theme="dark"] .sr-card--worksheet {
|
||||||
background: linear-gradient(180deg, rgba(25, 39, 44, 0.94), rgba(16, 27, 31, 0.98));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 94%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-card--prepost,
|
[data-theme="dark"] .sr-card--prepost,
|
||||||
[data-theme="dark"] .sr-card--rubric {
|
[data-theme="dark"] .sr-card--rubric {
|
||||||
background: linear-gradient(180deg, rgba(29, 43, 38, 0.92), rgba(18, 30, 27, 0.96));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 92%, transparent), color-mix(in srgb, var(--bg-surface) 96%, transparent));
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-stat,
|
[data-theme="dark"] .sr-stat,
|
||||||
[data-theme="dark"] .sr-readiness span,
|
[data-theme="dark"] .sr-readiness span,
|
||||||
|
|
@ -1545,8 +1541,8 @@
|
||||||
[data-theme="dark"] .sr-prepost__field input,
|
[data-theme="dark"] .sr-prepost__field input,
|
||||||
[data-theme="dark"] .sr-feedback,
|
[data-theme="dark"] .sr-feedback,
|
||||||
[data-theme="dark"] .sr-nextline {
|
[data-theme="dark"] .sr-nextline {
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: color-mix(in srgb, var(--text-strong) 5.5%, transparent);
|
||||||
border-color: rgba(203, 227, 220, 0.12);
|
border-color: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-tx__head,
|
[data-theme="dark"] .sr-tx__head,
|
||||||
[data-theme="dark"] .sr-turn + .sr-turn,
|
[data-theme="dark"] .sr-turn + .sr-turn,
|
||||||
|
|
@ -1554,35 +1550,35 @@
|
||||||
[data-theme="dark"] .sr-ws-limitations,
|
[data-theme="dark"] .sr-ws-limitations,
|
||||||
[data-theme="dark"] .sr-prepost__actions,
|
[data-theme="dark"] .sr-prepost__actions,
|
||||||
[data-theme="dark"] .sr-feedback__src {
|
[data-theme="dark"] .sr-feedback__src {
|
||||||
border-color: rgba(203, 227, 220, 0.1);
|
border-color: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-turn__said--client {
|
[data-theme="dark"] .sr-turn__said--client {
|
||||||
color: rgba(234, 240, 241, 0.74);
|
color: color-mix(in srgb, var(--text-strong) 74%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-note--ai {
|
[data-theme="dark"] .sr-note--ai {
|
||||||
background: rgba(92, 151, 190, 0.12);
|
background: color-mix(in srgb, var(--info-solid) 12%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-note--warn {
|
[data-theme="dark"] .sr-note--warn {
|
||||||
background: rgba(212, 162, 74, 0.12);
|
background: color-mix(in srgb, var(--warn-solid) 12%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-turn--active .sr-turn__said {
|
[data-theme="dark"] .sr-turn--active .sr-turn__said {
|
||||||
background: rgba(125, 162, 148, 0.14);
|
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-practice-return {
|
[data-theme="dark"] .sr-practice-return {
|
||||||
border-color: rgba(125, 162, 148, 0.32);
|
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||||
background: rgba(32, 50, 45, 0.88);
|
background: color-mix(in srgb, var(--bg-surface-2) 88%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-practice-return__facts > div {
|
[data-theme="dark"] .sr-practice-return__facts > div {
|
||||||
border-color: rgba(203, 227, 220, 0.13);
|
border-color: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: color-mix(in srgb, var(--text-strong) 5.5%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-practice-return.is-error {
|
[data-theme="dark"] .sr-practice-return.is-error {
|
||||||
border-color: rgba(224, 139, 130, 0.42);
|
border-color: color-mix(in srgb, var(--crit-solid) 42%, transparent);
|
||||||
background: rgba(93, 42, 42, 0.3);
|
background: color-mix(in srgb, var(--crit-solid) 30%, transparent);
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .sr-feedback--filled {
|
[data-theme="dark"] .sr-feedback--filled {
|
||||||
border-color: rgba(203, 227, 220, 0.12);
|
border-color: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
background: linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92));
|
background: linear-gradient(135deg, color-mix(in srgb, var(--bg-app) 98%, transparent), color-mix(in srgb, var(--bg-surface) 92%, transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
===================================================================== */
|
===================================================================== */
|
||||||
|
|
||||||
.sx-alliance-checkpoint {
|
.sx-alliance-checkpoint {
|
||||||
--checkpoint-ink: var(--text-strong, #132822);
|
--checkpoint-ink: var(--text-strong);
|
||||||
--checkpoint-muted: var(--text-muted, #66736f);
|
--checkpoint-muted: var(--text-muted);
|
||||||
--checkpoint-line: color-mix(in srgb, var(--accent, #2f695b) 18%, transparent);
|
--checkpoint-line: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
--checkpoint-hair: color-mix(in srgb, var(--checkpoint-ink) 12%, transparent);
|
--checkpoint-hair: color-mix(in srgb, var(--checkpoint-ink) 12%, transparent);
|
||||||
--checkpoint-title: clamp(1.02rem, 1.5vw, 1.24rem);
|
--checkpoint-title: clamp(1.02rem, 1.5vw, 1.24rem);
|
||||||
--checkpoint-pad: clamp(16px, 1.6vw, 24px);
|
--checkpoint-pad: clamp(16px, 1.6vw, 24px);
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
margin: 14px auto 0;
|
margin: 14px auto 0;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
color: var(--checkpoint-ink);
|
color: var(--checkpoint-ink);
|
||||||
background: color-mix(in srgb, var(--surface, #fff) 94%, var(--accent, #2f695b) 6%);
|
background: color-mix(in srgb, var(--surface) 94%, var(--accent) 6%);
|
||||||
border: 1px solid var(--checkpoint-line);
|
border: 1px solid var(--checkpoint-line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
|
|
@ -50,9 +50,9 @@
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: var(--accent, #2f695b);
|
background: var(--accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent, #2f695b) 12%, transparent);
|
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint.is-loading .sx-alliance-checkpoint__signal {
|
.sx-alliance-checkpoint.is-loading .sx-alliance-checkpoint__signal {
|
||||||
|
|
@ -60,8 +60,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint.is-error {
|
.sx-alliance-checkpoint.is-error {
|
||||||
border-color: color-mix(in srgb, var(--crit-solid, #a13c3c) 34%, transparent);
|
border-color: color-mix(in srgb, var(--crit-solid) 34%, transparent);
|
||||||
background: color-mix(in srgb, var(--surface, #fff) 94%, var(--crit-tint, #faeae8) 6%);
|
background: color-mix(in srgb, var(--surface) 94%, var(--crit-tint) 6%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 확장 카드 공통 ───────────────────────────────────────────────── */
|
/* ── 확장 카드 공통 ───────────────────────────────────────────────── */
|
||||||
|
|
@ -69,12 +69,12 @@
|
||||||
.sx-alliance-checkpoint.is-expanded {
|
.sx-alliance-checkpoint.is-expanded {
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--checkpoint-pad);
|
padding: var(--checkpoint-pad);
|
||||||
border-color: color-mix(in srgb, var(--accent, #2f695b) 28%, transparent);
|
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
box-shadow: 0 18px 50px rgb(18 42 35 / 9%);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint.is-pre {
|
.sx-alliance-checkpoint.is-pre {
|
||||||
border-top: 3px solid var(--accent, #2f695b);
|
border-top: 3px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__head {
|
.sx-alliance-checkpoint__head {
|
||||||
|
|
@ -91,7 +91,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__eyebrow {
|
.sx-alliance-checkpoint__eyebrow {
|
||||||
color: var(--accent, #2f695b);
|
color: var(--accent);
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 760;
|
font-weight: 760;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
|
|
@ -136,12 +136,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__progress.is-done {
|
.sx-alliance-checkpoint__progress.is-done {
|
||||||
color: var(--accent, #2f695b);
|
color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--accent, #2f695b) 13%, transparent);
|
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__progress.is-done b {
|
.sx-alliance-checkpoint__progress.is-done b {
|
||||||
color: var(--accent, #2f695b);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 3축 ─────────────────────────────────────────────────────────── */
|
/* ── 3축 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
@ -216,25 +216,25 @@
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 7px 4px;
|
padding: 7px 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-surface-2, #f4f2ee);
|
background: var(--bg-surface-2);
|
||||||
border: 1px solid var(--checkpoint-hair);
|
border: 1px solid var(--checkpoint-hair);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
|
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__scale label:hover {
|
.sx-alliance-checkpoint__scale label:hover {
|
||||||
background: color-mix(in srgb, var(--accent, #2f695b) 8%, var(--bg-surface-2, #f4f2ee));
|
background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface-2));
|
||||||
border-color: color-mix(in srgb, var(--accent, #2f695b) 42%, transparent);
|
border-color: color-mix(in srgb, var(--accent) 42%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__scale label.is-selected {
|
.sx-alliance-checkpoint__scale label.is-selected {
|
||||||
color: var(--text-on-accent, #fbfaf8);
|
color: var(--text-on-accent);
|
||||||
background: var(--accent, #2f695b);
|
background: var(--accent);
|
||||||
border-color: var(--accent, #2f695b);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__scale label.is-selected:hover {
|
.sx-alliance-checkpoint__scale label.is-selected:hover {
|
||||||
background: var(--accent, #2f695b);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__scale input {
|
.sx-alliance-checkpoint__scale input {
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-alliance-checkpoint__scale label:focus-within {
|
.sx-alliance-checkpoint__scale label:focus-within {
|
||||||
outline: 3px solid color-mix(in srgb, var(--accent, #2f695b) 34%, transparent);
|
outline: 3px solid color-mix(in srgb, var(--accent) 34%, transparent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,7 +306,7 @@ fieldset[disabled] .sx-alliance-checkpoint__scale label {
|
||||||
|
|
||||||
.sx-alliance-checkpoint__error {
|
.sx-alliance-checkpoint__error {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
color: var(--crit-text, #a13c3c);
|
color: var(--crit-text);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +316,7 @@ fieldset[disabled] .sx-alliance-checkpoint__scale label {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
width: min(1180px, calc(100% - 32px));
|
width: min(1180px, calc(100% - 32px));
|
||||||
margin: 10px auto 0;
|
margin: 10px auto 0;
|
||||||
color: var(--text-muted, #66736f);
|
color: var(--text-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -47,9 +47,13 @@
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--dur-fast) var(--ease-out),
|
transition: transform var(--dur-fast) var(--ease-spring),
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
color var(--dur-fast) var(--ease-out);
|
color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
.vg-set__nav-item:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
.vg-set__nav-item:hover {
|
.vg-set__nav-item:hover {
|
||||||
background: var(--bg-surface-2);
|
background: var(--bg-surface-2);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font: 650 var(--fs-sm) / 1 var(--font-sans);
|
font: 650 var(--fs-sm) / 1 var(--font-sans);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-spring), color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g6-lenses button:active {
|
||||||
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
.g6-lenses button:hover {
|
.g6-lenses button:hover {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -23,9 +26,10 @@ body {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: var(--fs-body);
|
font-size: var(--fs-body);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
letter-spacing: 0;
|
letter-spacing: -0.01em;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
/* 한국어 조판: 기본값(normal)은 한글을 음절 단위로 끊어 "오전"이 "오/전",
|
/* 한국어 조판: 기본값(normal)은 한글을 음절 단위로 끊어 "오전"이 "오/전",
|
||||||
"검토 대기"가 "검/토 대기"로 쪼개진다. 어절 단위로 끊고, 끊을 수 없는 긴
|
"검토 대기"가 "검/토 대기"로 쪼개진다. 어절 단위로 끊고, 끊을 수 없는 긴
|
||||||
토큰(URL·ID)만 강제 줄바꿈한다. 세션 화면이 쓰던 규칙을 전역으로 올렸다. */
|
토큰(URL·ID)만 강제 줄바꿈한다. 세션 화면이 쓰던 규칙을 전역으로 올렸다. */
|
||||||
|
|
@ -37,9 +41,11 @@ body {
|
||||||
a {
|
a {
|
||||||
color: var(--accent-deep);
|
color: var(--accent-deep);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
transition: color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|
@ -61,13 +67,13 @@ svg {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 포커스 — 키보드 사용자만 ring (마우스 클릭엔 없음) */
|
/* 포커스 — 키보드 사용자만 고대비 ring (마우스 클릭엔 없음) */
|
||||||
:focus {
|
:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: none;
|
outline: 3px solid var(--focus-ring);
|
||||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
outline-offset: 2px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,14 @@
|
||||||
--radius-lg: 12px; /* 패널 */
|
--radius-lg: 12px; /* 패널 */
|
||||||
--gutter: 24px;
|
--gutter: 24px;
|
||||||
|
|
||||||
|
--bottom-bar-h: 60px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(28, 42, 42, 0.05);
|
--shadow-sm: 0 1px 2px rgba(28, 42, 42, 0.05);
|
||||||
--shadow-md: 0 4px 14px rgba(28, 42, 42, 0.07); /* 모달·팝오버만 */
|
--shadow-md: 0 4px 14px rgba(28, 42, 42, 0.07); /* 모달·팝오버만 */
|
||||||
|
--shadow-island: 0 8px 24px rgba(28, 42, 42, 0.08), 0 2px 6px rgba(28, 42, 42, 0.04);
|
||||||
|
--shadow-float: 0 14px 36px rgba(28, 42, 42, 0.12), 0 3px 10px rgba(28, 42, 42, 0.06);
|
||||||
|
|
||||||
/* ── 래스터 디자인 요소 ── */
|
/* ── 래스터 디자인 요소 ── */
|
||||||
--asset-warm-elements: url("/design-elements/clinical-paper-ambient.png");
|
--asset-warm-elements: url("/design-elements/clinical-paper-ambient.png");
|
||||||
|
|
@ -162,6 +168,7 @@
|
||||||
/* ── 모션 §3.9 ── */
|
/* ── 모션 §3.9 ── */
|
||||||
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
|
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
--ease-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
|
--ease-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
|
||||||
|
--ease-spring: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
--dur-fast: 120ms; /* hover·포커스 */
|
--dur-fast: 120ms; /* hover·포커스 */
|
||||||
--dur-base: 200ms; /* 버튼·토글·드롭다운 */
|
--dur-base: 200ms; /* 버튼·토글·드롭다운 */
|
||||||
--dur-slow: 320ms; /* 패널 전환·모달 */
|
--dur-slow: 320ms; /* 패널 전환·모달 */
|
||||||
|
|
|
||||||
BIN
apps/web/test-results/g7-voice-consent-desktop.png
Normal file
BIN
apps/web/test-results/g7-voice-consent-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
BIN
apps/web/test-results/g7-voice-consent-mobile.png
Normal file
BIN
apps/web/test-results/g7-voice-consent-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Loading…
Add table
Add a link
Reference in a new issue