[251022] fix: WebSpeech 이벤트 상태 디버그 추가

🕐 커밋 시간: 2025. 10. 22. 10:06:35

📊 변경 통계:
  • 총 파일: 5개
  • 추가: +332줄
  • 삭제: -6줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/WebSpeechEventDebug.jsx
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.jsx
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
     Added: handleMicClick()
    🔄 Modified: clearAllTimers()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/WebSpeechEventDebug.jsx (javascript):
     Added: WebSpeechEventDebug()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.jsx (javascript):
     Added: clearTimerRef(), clearIntervalRef(), clearAllTimers()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.module.less (unknown):
     Added: translate()
This commit is contained in:
2025-10-22 10:06:36 +09:00
parent 1abe8db65e
commit 96de37c44e
5 changed files with 2072 additions and 5 deletions

View File

@@ -21,6 +21,7 @@ import VoiceNotRecognized from './modes/VoiceNotRecognized';
import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle';
import VoicePromptScreen from './modes/VoicePromptScreen';
import VoiceResponse from './modes/VoiceResponse';
import WebSpeechEventDebug from './WebSpeechEventDebug';
import css from './VoiceInputOverlay.module.less';
const OverlayContainer = SpotlightContainerDecorator(
@@ -157,6 +158,10 @@ const VoiceInputOverlay = ({
const [sttResponseText, setSttResponseText] = useState('');
// 카운트다운 타이머 (15 -> 1)
const [countdown, setCountdown] = useState(15);
// WebSpeech 에러 메시지 저장
const [errorMessage, setErrorMessage] = useState('');
// WebSpeech 이벤트 로그 저장 (디버그용)
const [webSpeechEventLogs, setWebSpeechEventLogs] = useState([]);
// 검색 기록 관리 (localStorage 기반, 최근 5개)
const [searchHistory, setSearchHistory] = useState(() => {
const history = readLocalStorage(SEARCH_HISTORY_KEY, DEFAULT_SUGGESTIONS);
@@ -233,8 +238,45 @@ const VoiceInputOverlay = ({
webSpeechConfig
);
// Redux에서 STT 결과 가져오기
const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// Redux에서 STT 결과 및 에러 상태 가져오기
const { lastSTTText, sttTimestamp, webSpeech } = useSelector((state) => state.voice);
// 🎤 WebSpeech 이벤트 로그 관리 함수
const addWebSpeechEventLog = useCallback((event, details = '') => {
const timestamp = new Date().toLocaleTimeString();
const newLog = {
id: Date.now(),
timestamp,
event,
details,
type: getWebSpeechEventType(event),
};
if (DEBUG_MODE) {
console.log(`[WebSpeech Event] ${event}: ${details}`);
}
setWebSpeechEventLogs((prev) => {
const updated = [newLog, ...prev];
return updated.slice(0, 10); // 최근 10개만 유지
});
}, []);
// 이벤트 타입별 색상 분류
const getWebSpeechEventType = useCallback((event) => {
const types = {
INIT: 'info',
START: 'success',
RESULT: 'info',
RESULT_FINAL: 'success',
ERROR: 'error',
END: 'warning',
STOP: 'warning',
ABORT: 'error',
RESTART: 'info',
};
return types[event] || 'info';
}, []);
// Redux에서 shopperHouse 검색 결과 가져오기 (simplified ref usage)
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
@@ -265,6 +307,137 @@ const VoiceInputOverlay = ({
}
}, [shopperHouseData, isVisible]);
// 🚨 WebSpeech 에러 감시 및 처리 (Phase 1)
useEffect(() => {
if (webSpeech.error) {
console.error('[VoiceInput] 🔴 WebSpeech error detected:', webSpeech.error);
console.error('[VoiceInput] ├─ error type:', webSpeech.error);
console.error('[VoiceInput] ├─ message:', webSpeech.message);
console.error('[VoiceInput] ├─ current mode:', currentMode);
console.error('[VoiceInput] └─ is listening:', isListening);
// WebSpeech 이벤트 로그 기록
addWebSpeechEventLog('ERROR', `${webSpeech.error}: ${webSpeech.message || 'No message'}`);
// 사용자에게 표시할 에러 메시지 생성
let userErrorMessage = '음성 인식에 문제가 발생했습니다.';
if (webSpeech.error === 'no-speech') {
userErrorMessage = '음성이 감지되지 않았습니다. 다시 시도해주세요.';
} else if (webSpeech.error === 'audio-capture') {
userErrorMessage = '마이크에 접근할 수 없습니다. 설정을 확인해주세요.';
} else if (webSpeech.error === 'not-allowed') {
userErrorMessage = '마이크 사용 권한이 필요합니다. 권한을 허용해주세요.';
} else if (webSpeech.error === 'network') {
userErrorMessage = '네트워크 연결을 확인해주세요.';
}
setErrorMessage(userErrorMessage);
// 에러 발생시 모든 타이머 정리
clearTimerRef(listeningTimerRef);
clearTimerRef(silenceDetectionTimerRef);
clearIntervalRef(countdownIntervalRef);
// Ref 초기화
listeningTimerRef.current = null;
silenceDetectionTimerRef.current = null;
countdownIntervalRef.current = null;
// 음성 인식 중지
if (isListening) {
console.log('[VoiceInput] 🛑 Stopping listening due to error');
stopListening();
}
// 에러 모드로 전환 (NOTRECOGNIZED) - 사용자에게 에러 상태 표시
if (isVisible) {
console.log('[VoiceInput] 🔀 Switching to NOTRECOGNIZED mode due to error');
setCurrentMode(VOICE_MODES.NOTRECOGNIZED);
setVoiceInputMode(null);
// 일부 에러 타입은 자동 재시작 시도
const autoRetryErrors = ['network', 'aborted', 'service-not-allowed'];
if (autoRetryErrors.includes(webSpeech.error)) {
console.log('[VoiceInput] 🔄 Auto-restarting for error type:', webSpeech.error);
setTimeout(() => {
restartWebSpeech();
}, 2000); // 2초 후 자동 재시작
}
}
} else {
// 에러가 없으면 메시지 정리
setErrorMessage('');
}
}, [
webSpeech.error,
currentMode,
isListening,
isVisible,
stopListening,
restartWebSpeech,
addWebSpeechEventLog,
]);
// 🎤 WebSpeech 이벤트 감지 (전용 디버그용)
// WebSpeech 시작 감지
useEffect(() => {
if (isListening && currentMode === VOICE_MODES.LISTENING) {
addWebSpeechEventLog('START', `Listening started - Mode: ${currentMode}`);
}
}, [isListening, currentMode, addWebSpeechEventLog]);
// WebSpeech 종료 감지
useEffect(() => {
if (
!isListening &&
currentMode === VOICE_MODES.LISTENING &&
webSpeechEventLogs.some((log) => log.event === 'START')
) {
addWebSpeechEventLog('END', 'Listening ended naturally');
}
}, [isListening, currentMode, webSpeechEventLogs, addWebSpeechEventLog]);
// Interim 텍스트 변화 감지
useEffect(() => {
if (interimText && interimText.trim().length > 0) {
const lastLog = webSpeechEventLogs[0];
const now = Date.now();
// 마지막 RESULT 로그가 1초 이내면 스킵 (과도한 로그 방지)
if (!lastLog || lastLog.event !== 'RESULT' || now - lastLog.id > 1000) {
const truncated =
interimText.length > 30 ? interimText.substring(0, 30) + '...' : interimText;
addWebSpeechEventLog('RESULT', `Interim: "${truncated}"`);
}
}
}, [interimText, webSpeechEventLogs, addWebSpeechEventLog]);
// 마이크 버튼 클릭 감지 (수동 중지)
useEffect(() => {
const micButtonElement = document.getElementById(MIC_SPOTLIGHT_ID);
const handleMicClick = () => {
if (isListening) {
addWebSpeechEventLog('STOP', 'Manually stopped by user');
}
};
if (micButtonElement) {
micButtonElement.addEventListener('click', handleMicClick);
return () => {
micButtonElement.removeEventListener('click', handleMicClick);
};
}
}, [isListening, addWebSpeechEventLog]);
// Overlay 닫을 때 감지
useEffect(() => {
if (!isVisible && isListening) {
addWebSpeechEventLog('ABORT', 'Overlay closed - listening aborted');
}
}, [isVisible, isListening, addWebSpeechEventLog]);
// ShopperHouse API 응답 수신 시 overlay 닫기
useEffect(() => {
if (DEBUG_MODE) {
@@ -603,6 +776,52 @@ const VoiceInputOverlay = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 🔄 WebSpeech 에러 재시작 함수
const restartWebSpeech = useCallback(() => {
if (DEBUG_MODE) {
console.log('[VoiceInput] 🔄 Restarting WebSpeech after error');
}
// 이벤트 로그 기록
addWebSpeechEventLog('RESTART', 'WebSpeech restarted after error or manual action');
// 에러 상태 정리 (Redux)
dispatch(clearSTTText());
// 모든 타이머 정리
clearTimerRef(listeningTimerRef);
clearTimerRef(silenceDetectionTimerRef);
clearIntervalRef(countdownIntervalRef);
// Ref 초기화
listeningTimerRef.current = null;
silenceDetectionTimerRef.current = null;
countdownIntervalRef.current = null;
interimTextRef.current = '';
// 에러 메시지 정리
setErrorMessage('');
// WebSpeech 이벤트 로그 정리
setWebSpeechEventLogs([]);
// Input 필드 초기화
if (onSearchChange) {
onSearchChange({ value: '' });
}
// PROMPT 모드로 복귀
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
// 약간의 지연 후 새로 시작 (안정성을 위해)
setTimeout(() => {
if (DEBUG_MODE) {
console.log('[VoiceInput] ✅ Restart complete - ready for new input');
}
}, 300);
}, [dispatch, onSearchChange, addWebSpeechEventLog]);
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출
const handleSuggestionClick = useCallback(
(suggestion) => {
@@ -754,10 +973,15 @@ const VoiceInputOverlay = ({
case VOICE_MODES.NOTRECOGNIZED:
if (DEBUG_MODE) {
console.log(
'❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized'
'❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized with error message'
);
}
return <VoiceNotRecognized />;
return (
<VoiceNotRecognized
prompt={errorMessage || '음성 인식에 문제가 발생했습니다.'}
onRestart={restartWebSpeech}
/>
);
case VOICE_MODES.MODE_3:
// 추후 MODE_3 컴포넌트 추가
return <VoiceNotRecognized />;
@@ -784,6 +1008,8 @@ const VoiceInputOverlay = ({
interimText,
sttResponseText,
countdown,
errorMessage,
restartWebSpeech,
]);
// 마이크 버튼 포커스 핸들러 (VUI)
@@ -867,6 +1093,7 @@ const VoiceInputOverlay = ({
setVoiceInputMode(null);
setCurrentMode(VOICE_MODES.PROMPT);
setSttResponseText('');
setErrorMessage('');
onClose();
}, [onClose]);
@@ -1022,7 +1249,7 @@ const VoiceInputOverlay = ({
fontSize: '18px',
minWidth: '350px',
lineHeight: '1.6',
fontFamily: 'monospace',
fontFamily: '"LG Smart UI"',
}}
>
<div
@@ -1219,6 +1446,11 @@ const VoiceInputOverlay = ({
{/* 모드별 컨텐츠 */}
<div className={css.modeContent}>{renderModeContent}</div>
</OverlayContainer>
{/* WebSpeech 이벤트 전용 디버그 - 우측 하단에 표시 */}
{DEBUG_MODE && webSpeechEventLogs.length > 0 && (
<WebSpeechEventDebug logs={webSpeechEventLogs} />
)}
</div>
</TFullPopup>
);

View File

@@ -316,3 +316,112 @@
z-index: 1002;
pointer-events: none; // 빈 공간 클릭 시 dimBackground로 이벤트 전달
}
// WebSpeech 이벤트 전용 디버그 UI
.webSpeechEventDebug {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.9);
padding: 16px;
border-radius: 8px;
font-size: 18px;
min-width: 350px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
font-family: "LG Smart UI";
border: 1px solid rgba(255, 255, 255, 0.1);
z-index: 10006; // 가장 높은 z-index
pointer-events: auto; // 마우스 이벤트 허용
}
.eventDebugHeader {
font-weight: bold;
margin-bottom: 12px;
border-bottom: 2px solid #fff;
padding-bottom: 8px;
font-size: 20px;
color: #fff;
font-family: "LG Smart UI";
}
.eventDebugLogs {
max-height: 220px;
overflow-y: auto;
}
.eventLog {
margin-bottom: 8px;
line-height: 1.4;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-bottom: none;
}
.logTime {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
margin-bottom: 2px;
font-family: monospace;
}
.logEvent {
color: #fff;
font-weight: bold;
font-size: 16px;
font-family: "LG Smart UI";
}
.logDetails {
color: rgba(255, 255, 255, 0.8);
font-size: 15px;
margin-top: 2px;
word-break: break-all;
padding-left: 8px;
border-left: 2px solid rgba(255, 255, 255, 0.2);
font-family: "LG Smart UI";
}
// 이벤트 타입별 색상
&.success .logEvent { color: #4CAF50; }
&.success .logDetails { border-left-color: #4CAF50; }
&.error .logEvent { color: #F44336; }
&.error .logDetails { border-left-color: #F44336; }
&.warning .logEvent { color: #FF9800; }
&.warning .logDetails { border-left-color: #FF9800; }
&.info .logEvent { color: #2196F3; }
&.info .logDetails { border-left-color: #2196F3; }
}
.noEvents {
color: rgba(255, 255, 255, 0.5);
font-style: italic;
text-align: center;
padding: 20px 0;
font-family: "LG Smart UI";
}
// 스크롤바 스타일링
.eventDebugLogs::-webkit-scrollbar {
width: 6px;
}
.eventDebugLogs::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.eventDebugLogs::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.4);
}
}

View File

@@ -0,0 +1,53 @@
// src/views/SearchPanel/VoiceInputOverlay/WebSpeechEventDebug.jsx
import React from 'react';
import PropTypes from 'prop-types';
import css from './VoiceInputOverlay.module.less';
/**
* WebSpeechEventDebug - WebSpeech API 이벤트 전용 디버그 컴포넌트
* DEBUG_MODE 또는 IS_DEBUG가 true일 때만 표시됨
* WebSpeech API의 이벤트 발생 상황을 실시간으로 모니터링
* @param {Array} logs - WebSpeech 이벤트 로그 배열
*/
const WebSpeechEventDebug = ({ logs }) => {
// DEBUG_MODE 체크 (기존과 동일한 조건)
const DEBUG_MODE = process.env.NODE_ENV === 'development' || true; // 항상 표시 (개발 중)
if (!DEBUG_MODE || !logs || logs.length === 0) {
return null;
}
return (
<div className={css.webSpeechEventDebug}>
<div className={css.eventDebugHeader}>🎤 WebSpeech Events</div>
<div className={css.eventDebugLogs}>
{logs.map((log) => (
<div key={log.id} className={`${css.eventLog} ${css[log.type]}`}>
<div className={css.logTime}>[{log.timestamp}]</div>
<div className={css.logEvent}>{log.event}</div>
{log.details && <div className={css.logDetails}>{log.details}</div>}
</div>
))}
{logs.length === 0 && <div className={css.noEvents}>No WebSpeech events yet...</div>}
</div>
</div>
);
};
WebSpeechEventDebug.propTypes = {
logs: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
timestamp: PropTypes.string.isRequired,
event: PropTypes.string.isRequired,
details: PropTypes.string,
type: PropTypes.oneOf(['info', 'success', 'warning', 'error']).isRequired,
})
).isRequired,
};
WebSpeechEventDebug.defaultProps = {
logs: [],
};
export default WebSpeechEventDebug;

View File

@@ -0,0 +1,318 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less
@import "../../../style/CommonStyle.module.less";
// TFullPopup wrapper - TFullPopup의 기본 스타일을 override하지 않음
.voiceOverlayContainer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
pointer-events: all;
}
.dimBackground {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
cursor: pointer;
}
.contentArea {
position: absolute;
top: 0;
left: 0;
padding-left: 120px;
width: 100%;
height: 100%;
z-index: 1002;
pointer-events: none; // 빈 공간 클릭 시 dimBackground로 이벤트 전달
display: flex;
flex-direction: column;
align-items: center;
}
// 입력창과 마이크 버튼 영역 - SearchPanel.inputContainer와 동일 (210px 높이)
.inputWrapper {
width: 100%;
padding-top: 60px; // 위쪽 패딩 증가 (55px → 60px) - 포커스 테두리 여유 공간
padding-bottom: 60px; // 아래쪽 패딩 증가 (55px → 60px) - 포커스 테두리 여유 공간
padding-left: 60px;
padding-right: 60px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1003;
position: relative;
pointer-events: all; // 입력 영역은 클릭 가능
overflow: visible; // 포커스 테두리가 잘리지 않도록
.searchInputWrapper {
height: 100px;
display: flex;
justify-content: flex-start;
align-items: center;
min-height: 100px;
max-height: 100px;
overflow: visible; // 포커스 테두리가 잘리지 않도록
padding: 5px 0; // 위아래 여유 공간
> * {
margin-right: 15px;
&:last-child {
margin-right: 0;
}
}
}
}
.inputBox {
width: 880px;
height: 100px !important;
padding-left: 50px;
padding-right: 40px;
background: white;
border-radius: 1000px;
border: 5px solid #ccc;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1003;
position: relative;
> div:first-child {
margin: 0 !important;
width: calc(100% - 121px) !important;
height: 90px !important;
padding: 20px 40px 20px 0px !important;
border: none !important;
background-color: #fff !important;
input {
text-align: left;
color: black;
font-size: 42px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 42px;
outline: none;
border: none;
background: transparent;
}
* {
outline: none !important;
border: none !important;
}
}
&:focus-within,
&:focus {
border: 5px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
.microphoneButton {
width: 100px;
height: 100px;
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 1003;
.microphoneCircle {
width: 100%;
height: 100%;
position: relative;
background: white;
overflow: hidden;
border-radius: 1000px;
border: 5px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
.microphoneIcon {
height: 50px;
box-sizing: border-box;
transition: filter 0.3s ease;
}
}
&:hover {
.microphoneCircle {
// border-color: @PRIMARY_COLOR_RED;
border-color: white;
}
}
// active 상태 (음성 입력 모드 - 항상 빨간색)
&.active {
.microphoneCircle {
background-color: @PRIMARY_COLOR_RED;
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(229, 9, 20, 0.5);
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
}
}
}
&.active.focused {
.microphoneCircle {
background-color: @PRIMARY_COLOR_RED;
border-color: white;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// listening 상태 (배경 투명, 테두리 ripple 애니메이션)
&.listening {
.microphoneCircle {
background-color: transparent;
border-color: transparent; // 테두리 투명
box-shadow: none;
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘은 흰색 유지
}
}
}
}
// WebSpeech 마이크 버튼 (블루 계열)
.microphoneButtonWebSpeech {
width: 100px;
height: 100px;
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 1003;
.microphoneCircle {
width: 100%;
height: 100%;
position: relative;
background: white;
overflow: hidden;
border-radius: 1000px;
border: 5px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
.microphoneIcon {
height: 50px;
box-sizing: border-box;
transition: filter 0.3s ease;
}
}
&:hover {
.microphoneCircle {
border-color: white;
}
}
// active 상태 (음성 입력 모드 - 블루 색상)
&.active {
.microphoneCircle {
background-color: #4A90E2;
border-color: #4A90E2;
box-shadow: 0 0 22px 0 rgba(74, 144, 226, 0.5);
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
}
}
}
&.active.focused {
.microphoneCircle {
background-color: #4A90E2;
border-color: white;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// listening 상태 (배경 투명, 테두리 ripple 애니메이션)
&.listening {
.microphoneCircle {
background-color: transparent;
border-color: transparent; // 테두리 투명
box-shadow: none;
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘은 흰색 유지
}
}
}
}
// Ripple 애니메이션 (원형 테두리가 점에서 시작해서 그려짐)
.rippleSvg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.rippleCircle {
stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레)
stroke-dashoffset: 295.3; // 초기값: 완전히 숨김
transform-origin: center;
transform: rotate(-90deg); // 12시 방향에서 시작
animation: drawCircle 2s ease-in-out infinite;
}
// 15초 프로그레스 원 (countdown 기반)
.rippleCircleProgress {
stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레)
stroke-dashoffset: 295.3; // 초기값: 완전히 숨김
transform-origin: center;
transform: rotate(-90deg); // 12시 방향에서 시작
transition: stroke-dashoffset 1s linear; // 부드러운 전환
}
@keyframes drawCircle {
0% {
stroke-dashoffset: 295.3; // 점에서 시작
}
100% {
stroke-dashoffset: 0; // 원 완성 (계속 시계방향으로)
}
}
// 모드별 컨텐츠 영역 - 화면 중앙에 배치
.modeContent {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding-right: 120px;
z-index: 1002;
pointer-events: none; // 빈 공간 클릭 시 dimBackground로 이벤트 전달
}