@ihabdevteam/core 0.321.0 → 0.323.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +617 -0
  2. package/dist/components.cjs.js +183 -183
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +108 -66
  8. package/dist/packages/core/src/components/Accordion.js +3 -3
  9. package/dist/packages/core/src/components/Alert.js +2 -2
  10. package/dist/packages/core/src/components/BarChart.js +120 -119
  11. package/dist/packages/core/src/components/BottomNav.js +18 -18
  12. package/dist/packages/core/src/components/BulkBar.js +25 -25
  13. package/dist/packages/core/src/components/Button.js +16 -16
  14. package/dist/packages/core/src/components/Calendar.js +17 -17
  15. package/dist/packages/core/src/components/ChartSection.js +30 -30
  16. package/dist/packages/core/src/components/CountTile.js +38 -38
  17. package/dist/packages/core/src/components/DatePicker.js +59 -59
  18. package/dist/packages/core/src/components/Dialog.js +14 -14
  19. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
  20. package/dist/packages/core/src/components/FilterPanel.js +6 -6
  21. package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
  22. package/dist/packages/core/src/components/GameIntro.js +1 -1
  23. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  24. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  25. package/dist/packages/core/src/components/Icon.js +2 -2
  26. package/dist/packages/core/src/components/ItemActions.js +16 -16
  27. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  28. package/dist/packages/core/src/components/LineChart.js +24 -24
  29. package/dist/packages/core/src/components/Loader.js +5 -5
  30. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  31. package/dist/packages/core/src/components/PageTitle.js +2 -2
  32. package/dist/packages/core/src/components/Pagination.js +4 -4
  33. package/dist/packages/core/src/components/Panel.js +2 -2
  34. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  35. package/dist/packages/core/src/components/PanelRow.js +8 -15
  36. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  37. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  38. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  39. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  40. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  41. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  42. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  43. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
  44. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  45. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
  46. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  47. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  48. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  49. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  50. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  51. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  52. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  53. package/dist/packages/core/src/components/Rating.js +20 -20
  54. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  55. package/dist/packages/core/src/components/RingChart.js +5 -5
  56. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  57. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  58. package/dist/packages/core/src/components/SideNav.js +37 -37
  59. package/dist/packages/core/src/components/Slider.js +2 -2
  60. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  61. package/dist/packages/core/src/components/Table.js +257 -98
  62. package/dist/packages/core/src/components/Tabs.js +12 -12
  63. package/dist/packages/core/src/components/TextField.js +34 -34
  64. package/dist/packages/core/src/components/Toast.js +6 -6
  65. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  66. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  67. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  68. package/dist/packages/core/src/components/TopBanner.js +28 -28
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  70. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  71. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  72. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  73. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  74. package/dist/packages/core/src/components/Training/TrainingBlock.js +12 -12
  75. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  76. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  77. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  78. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  79. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  80. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +83 -83
  81. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  82. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
  83. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  84. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  85. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  86. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +13 -13
  87. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  88. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  89. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  90. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  91. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  92. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  93. package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
  94. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  95. package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
  96. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  97. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  98. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  99. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  100. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  101. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  102. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  103. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  104. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  105. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  106. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  107. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  108. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  109. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +18 -18
  110. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  111. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  112. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  113. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  114. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  115. package/dist/packages/core/src/components/Word/wordScoreStats.js +17 -17
  116. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  117. package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
  118. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  119. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  120. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  121. package/dist/packages/core/src/components/WordTable.js +130 -130
  122. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  123. package/dist/packages/core/src/components/barChartData.js +49 -29
  124. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  125. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  126. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  127. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  128. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  129. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  130. package/dist/packages/core/src/components/wordTableLayout.d.ts +22 -7
  131. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  132. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  133. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  134. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  135. package/dist/packages/core/src/hooks/inputModality.js +14 -14
  136. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  137. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  138. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  139. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  140. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  141. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  142. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  143. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  144. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  145. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  146. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  147. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  148. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  149. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  150. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  151. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  152. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  153. package/dist/packages/core/src/hooks/useTablePaging.d.ts +7 -7
  154. package/dist/packages/core/src/hooks/useTablePaging.js +40 -34
  155. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  156. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  157. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  158. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  159. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  160. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
  161. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  162. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  163. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  164. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  165. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  166. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  167. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  168. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  169. package/dist/packages/core/src/utils/clipboard.js +3 -3
  170. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  171. package/dist/packages/core/src/utils/date.js +12 -12
  172. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  173. package/dist/packages/core/src/utils/devWarn.js +7 -7
  174. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  175. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  176. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  177. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  178. package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
  179. package/dist/packages/core/src/utils/format.js +4 -4
  180. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  181. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  182. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  183. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  184. package/dist/packages/core/src/utils/navScroll.js +6 -6
  185. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  186. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  187. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  188. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  189. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  190. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  191. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  192. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  193. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  194. package/dist/packages/core/src/utils/pressable.js +3 -3
  195. package/dist/packages/core/src/utils/safeHref.js +4 -4
  196. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  197. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  198. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  199. package/dist/packages/core/src/utils/speak.js +12 -12
  200. package/dist/packages/core/src/utils/textSort.d.ts +5 -5
  201. package/dist/packages/core/src/utils/textSort.js +47 -47
  202. package/dist/packages/core/src/utils/theme.js +3 -3
  203. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  204. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  205. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  206. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
  207. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  208. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  209. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  210. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  211. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  212. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  213. package/dist/packages/core/src/utils/wornDate.js +14 -14
  214. package/dist/packages/core/src/version.d.ts +1 -1
  215. package/dist/packages/core/src/version.js +1 -1
  216. package/dist/utils.cjs.js +4 -4
  217. package/package.json +1 -1
@@ -29,7 +29,7 @@ import { loadNoiseSettings, saveNoiseSettings, reportAppliedNoiseSettings } from
29
29
  /** 판 중 소음 설정 변경을 저장하기 전 기다리는 시간(ms) — 멈춘 뒤 이만큼 지나면 마지막 값만 저장한다. */
30
30
  const NOISE_SAVE_DEBOUNCE_MS = 300;
31
31
  /* 움직임 최소화면 닫힘 대기를 0 으로 — 사라지는 애니메이션을 보여 줄 까닭이 없다. (공용 훅이 생기면 그것으로 합친다.) */
32
- const 움직임최소화 = () => {
32
+ const prefersReducedMotion = () => {
33
33
  try {
34
34
  return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
35
35
  }
@@ -38,8 +38,8 @@ const 움직임최소화 = () => {
38
38
  }
39
39
  };
40
40
  /** 결과를 계산하는 중인 결과 창 자리 — 이 객체와 **같은 것**이면 계산 중이다(결과 창의 '완료' 를 막는다). */
41
- const 결과계산중 = Object.freeze({});
42
- import { 키보드로눌렀나 } from '../../hooks/inputModality';
41
+ const RESULT_PENDING = Object.freeze({});
42
+ import { wasKeyboardPress } from '../../hooks/inputModality';
43
43
  // 문장 4종은 각 PagePopup 래퍼에서 useTrainingSessionLog로 이미 기록 → 베이스에서 중복 방지
44
44
  const SENTENCE_TIME_LOGGED_TYPES = new Set(['LISTEN_IN_NOISE', 'SHORT_STORY', 'LONG_STORY', 'ORDERING_SENTENCE']);
45
45
  /** 결과 창 자료에서 정답/전체를 센다 — 결과 항목 형태가 훈련 타입마다 달라(소음 하 듣기/문장 순서화는 correct 대신
@@ -57,7 +57,7 @@ const SENTENCE_TIME_LOGGED_TYPES = new Set(['LISTEN_IN_NOISE', 'SHORT_STORY', 'L
57
57
  * > 안 터지다가 옆 함수에서는 이미 터져 있었다). 여기는 **뜻이 같아서** 안 터진다 —
58
58
  * > 그 둘이 갈라지는 날이 오면 이 자리가 첫 판이다.
59
59
  */
60
- function 판결과셈(resultOverlay) {
60
+ function countRoundResult(resultOverlay) {
61
61
  const ro = resultOverlay;
62
62
  /* `results` 가 이긴다 — 둘 다 오면 그쪽이 코어가 지은 이름이다 */
63
63
  const results = (ro?.results || ro?.answers || []);
@@ -211,28 +211,28 @@ function TrainingPagePopupInner(props) {
211
211
  }, []);
212
212
  /* 판 중 변경의 저장은 **멈춘 뒤 300ms 에 마지막 값만** 보낸다 — 전에는 슬라이더를 끄는 동안 움직일 때마다 저장이 나갔다
213
213
  (한 번 끌면 수십 번의 네트워크 쓰기, 늦게 도착한 앞 값이 뒤 값을 덮을 수도). 떠날 때·pagehide·탭 숨김에는 남은 값을 즉시 보낸다. */
214
- const 저장대기Ref = useRef(null);
215
- const 저장타이머Ref = useRef(null);
214
+ const pendingSaveRef = useRef(null);
215
+ const saveTimerRef = useRef(null);
216
216
  const flushNoiseSave = useCallback(() => {
217
- if (저장타이머Ref.current !== null) {
218
- clearTimeout(저장타이머Ref.current);
219
- 저장타이머Ref.current = null;
217
+ if (saveTimerRef.current !== null) {
218
+ clearTimeout(saveTimerRef.current);
219
+ saveTimerRef.current = null;
220
220
  }
221
- const 남은값 = 저장대기Ref.current;
222
- 저장대기Ref.current = null;
223
- if (남은값)
224
- saveNoiseSettings(남은값);
221
+ const pending = pendingSaveRef.current;
222
+ pendingSaveRef.current = null;
223
+ if (pending)
224
+ saveNoiseSettings(pending);
225
225
  }, []);
226
226
  useEffect(() => {
227
227
  if (typeof window === 'undefined')
228
228
  return;
229
- const 숨김 = () => { if (document.visibilityState === 'hidden')
229
+ const onHidden = () => { if (document.visibilityState === 'hidden')
230
230
  flushNoiseSave(); };
231
231
  window.addEventListener('pagehide', flushNoiseSave);
232
- document.addEventListener('visibilitychange', 숨김);
232
+ document.addEventListener('visibilitychange', onHidden);
233
233
  return () => {
234
234
  window.removeEventListener('pagehide', flushNoiseSave);
235
- document.removeEventListener('visibilitychange', 숨김);
235
+ document.removeEventListener('visibilitychange', onHidden);
236
236
  flushNoiseSave();
237
237
  };
238
238
  }, [flushNoiseSave]);
@@ -249,10 +249,10 @@ function TrainingPagePopupInner(props) {
249
249
  }
250
250
  // noiseOverride 판은 저장하지 않는다 — 판 중에 바꾼 것도 이 판에만.
251
251
  if (!noiseOverrideRef.current) {
252
- 저장대기Ref.current = merged;
253
- if (저장타이머Ref.current !== null)
254
- clearTimeout(저장타이머Ref.current);
255
- 저장타이머Ref.current = setTimeout(flushNoiseSave, NOISE_SAVE_DEBOUNCE_MS);
252
+ pendingSaveRef.current = merged;
253
+ if (saveTimerRef.current !== null)
254
+ clearTimeout(saveTimerRef.current);
255
+ saveTimerRef.current = setTimeout(flushNoiseSave, NOISE_SAVE_DEBOUNCE_MS);
256
256
  }
257
257
  reportAppliedNoiseSettings(merged);
258
258
  }, [flushNoiseSave]);
@@ -278,7 +278,7 @@ function TrainingPagePopupInner(props) {
278
278
  비워, 같은 문항을 다시 풀면 옛 선택 강조·옛 배치(순서화는 정답 배치일 수도)가 그대로 남았다. */
279
279
  const [replayCount, setReplayCount] = useState(0);
280
280
  // 다시하기 전 판들의 정답/전체 — 완료 때 onComplete 에 함께 넘긴다
281
- const 앞판들Ref = useRef([]);
281
+ const previousRoundsRef = useRef([]);
282
282
  const [mode, setMode] = useState('center');
283
283
  const [closing, setClosing] = useState(false);
284
284
  // 닫힘 애니메이션(PAGE_POPUP_CLOSE_MS) 동안 다른 닫기 경로(ESC/닫기 버튼/완료)가 또 실행되면
@@ -300,7 +300,7 @@ function TrainingPagePopupInner(props) {
300
300
  closingRef.current = false;
301
301
  setClosing(false);
302
302
  }
303
- }, 움직임최소화() ? 0 : PAGE_POPUP_CLOSE_MS);
303
+ }, prefersReducedMotion() ? 0 : PAGE_POPUP_CLOSE_MS);
304
304
  }, []);
305
305
  useEffect(() => () => { if (closeTimerRef.current)
306
306
  clearTimeout(closeTimerRef.current); }, []);
@@ -350,7 +350,7 @@ function TrainingPagePopupInner(props) {
350
350
  const [resultOverlay, setResultOverlay] = useState(null);
351
351
  /* 결과 요청 세대 — 결과보기가 올리고, 창을 닫거나(다시하기·완료·시나리오 초기화) 새로 요청하면 앞 요청은 늦은 것이 된다.
352
352
  결과 계산이 비동기일 때 늦게 온 옛 결과가 새 판 위에 결과 창을 다시 띄우거나 2회차 결과를 1회차 것으로 덮었다. */
353
- const 결과세대Ref = useRef(0);
353
+ const resultGenerationRef = useRef(0);
354
354
  /**
355
355
  * **판 번호** — 한 판은 결과창이 닫힐 때 끝난다(다시하기도 닫고 시작한다).
356
356
  *
@@ -359,8 +359,8 @@ function TrainingPagePopupInner(props) {
359
359
  * 것은 **판이 다시 끝난 것이 아니다.** 그래서 "몇 번째 결과창인가" 가 아니라
360
360
  * **"몇 번째 판인가"** 로 가린다.
361
361
  */
362
- const 판번호Ref = useRef(0);
363
- const 결과창닫기 = () => { 결과세대Ref.current += 1; 판번호Ref.current += 1; setResultOverlay(null); };
362
+ const roundNoRef = useRef(0);
363
+ const closeResultOverlay = () => { resultGenerationRef.current += 1; roundNoRef.current += 1; setResultOverlay(null); };
364
364
  const [noticeMessage, setNoticeMessage] = useState('');
365
365
  const [flowState, setFlowState] = useState(() => createScenarioFlowState());
366
366
  const prevPageRef = useRef(typeof pageIndex === 'number' ? pageIndex : null);
@@ -370,7 +370,7 @@ function TrainingPagePopupInner(props) {
370
370
  if (!hasScenario && !isChildrenMode)
371
371
  return;
372
372
  setFlowState(createScenarioFlowState());
373
- 결과창닫기();
373
+ closeResultOverlay();
374
374
  // eslint-disable-next-line react-hooks/exhaustive-deps
375
375
  }, [hasScenario, scenarioId, isChildrenMode]);
376
376
  /* eslint-enable react-hooks/set-state-in-effect */
@@ -434,14 +434,14 @@ function TrainingPagePopupInner(props) {
434
434
  const pageId = hasScenario
435
435
  ? getScenarioPageId(currentScenarioPage, effectivePageIndex)
436
436
  : (currentChildrenPage?.id || `children-page-${effectivePageIndex ?? 0}`);
437
- const 지금 = new Date().toISOString();
437
+ const nowIso = new Date().toISOString();
438
438
  for (let i = 0; i <= effectiveStepIndex; i += 1) {
439
439
  const stepKey = hasScenario
440
440
  ? getScenarioStepKey(currentScenarioSteps[i], i)
441
441
  : `step-${i}`;
442
- const k = 보인때열쇠(pageId, stepKey);
443
- if (!스텝보인때.current.has(k))
444
- 스텝보인때.current.set(k, 지금);
442
+ const k = shownAtKey(pageId, stepKey);
443
+ if (!stepShownAt.current.has(k))
444
+ stepShownAt.current.set(k, nowIso);
445
445
  }
446
446
  /* `currentScenarioSteps` 는 그릴 때마다 새 배열이라 의존성에 넣으면 매번 돈다 —
447
447
  쪽·스텝 번호가 바뀔 때만 돌면 되고, 그 둘이 바뀌면 스텝 목록도 함께 바뀐다. */
@@ -548,8 +548,8 @@ function TrainingPagePopupInner(props) {
548
548
  * `state` 가 아니라 `ref` 다: 이 값은 **그릴 것을 바꾸지 않는다.** state 로 두면 스텝이
549
549
  * 바뀔 때마다 한 번씩 더 그린다.
550
550
  */
551
- const 스텝보인때 = useRef(new Map());
552
- const 보인때열쇠 = (pageId, stepKey) => `${pageId}\u0000${stepKey}`;
551
+ const stepShownAt = useRef(new Map());
552
+ const shownAtKey = (pageId, stepKey) => `${pageId}\u0000${stepKey}`;
553
553
  const setScenarioAnswer = useCallback((pageId, stepKey, payload) => {
554
554
  setFlowState((prev) => {
555
555
  const pageAnswers = prev.answersByPage[pageId] || {};
@@ -566,14 +566,14 @@ function TrainingPagePopupInner(props) {
566
566
  - `shownAt` 은 위 `스텝보인때` 에 적어 둔 것을 꺼낸다.
567
567
  글자꼴은 `Z` 다 — **여기서 짓는 때는 epoch 하나**라 지킬 지역 시계가 없다
568
568
  (앱이 글자로 보낸 때를 안 고치는 것과 다른 자리다, `trainingItemResults` 머리글). */
569
- const 얹은payload = withStepTimes(payload, 스텝보인때.current.get(보인때열쇠(pageId, stepKey)), incomingTs);
569
+ const payloadWithTimes = withStepTimes(payload, stepShownAt.current.get(shownAtKey(pageId, stepKey)), incomingTs);
570
570
  return {
571
571
  ...prev,
572
572
  answersByPage: {
573
573
  ...prev.answersByPage,
574
574
  [pageId]: {
575
575
  ...pageAnswers,
576
- [stepKey]: 얹은payload,
576
+ [stepKey]: payloadWithTimes,
577
577
  },
578
578
  },
579
579
  };
@@ -691,8 +691,8 @@ function TrainingPagePopupInner(props) {
691
691
  return baseResult;
692
692
  };
693
693
  const handleViewResults = () => {
694
- const 내결과 = ++결과세대Ref.current;
695
- const 늦음 = () => 내결과 !== 결과세대Ref.current;
694
+ const myResult = ++resultGenerationRef.current;
695
+ const isStale = () => myResult !== resultGenerationRef.current;
696
696
  try {
697
697
  if (typeof document !== 'undefined' && document.body) {
698
698
  try {
@@ -706,11 +706,11 @@ function TrainingPagePopupInner(props) {
706
706
  catch (e) { }
707
707
  if (hasScenario) {
708
708
  const maybe = buildScenarioResult();
709
- setResultOverlay(결과계산중);
709
+ setResultOverlay(RESULT_PENDING);
710
710
  const maybeAny = maybe;
711
711
  Promise.resolve(maybeAny)
712
712
  .then((data) => {
713
- if (늦음())
713
+ if (isStale())
714
714
  return;
715
715
  if (data)
716
716
  setResultOverlay(data);
@@ -720,7 +720,7 @@ function TrainingPagePopupInner(props) {
720
720
  .catch((err) => {
721
721
  if (import.meta.env?.DEV)
722
722
  console.error('viewResults scenario handler failed', err);
723
- if (늦음())
723
+ if (isStale())
724
724
  return;
725
725
  setResultOverlay({ isEmpty: true });
726
726
  });
@@ -728,11 +728,11 @@ function TrainingPagePopupInner(props) {
728
728
  }
729
729
  if (isChildrenMode) {
730
730
  const maybe = buildChildrenResult();
731
- setResultOverlay(결과계산중);
731
+ setResultOverlay(RESULT_PENDING);
732
732
  const maybeAny = maybe;
733
733
  Promise.resolve(maybeAny)
734
734
  .then((data) => {
735
- if (늦음())
735
+ if (isStale())
736
736
  return;
737
737
  if (data)
738
738
  setResultOverlay(data);
@@ -742,7 +742,7 @@ function TrainingPagePopupInner(props) {
742
742
  .catch((err) => {
743
743
  if (import.meta.env?.DEV)
744
744
  console.error('viewResults children handler failed', err);
745
- if (늦음())
745
+ if (isStale())
746
746
  return;
747
747
  setResultOverlay({ isEmpty: true });
748
748
  });
@@ -769,16 +769,16 @@ function TrainingPagePopupInner(props) {
769
769
  setResultOverlay(maybeAny);
770
770
  }
771
771
  else {
772
- setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? 결과계산중 : {});
772
+ setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? RESULT_PENDING : {});
773
773
  }
774
774
  // 결과가 비었거나 실패하면 막지 않는다(전과 같이 빈 창) — 계산 중 표시만 걷는다
775
- const 계산중걷기 = () => { if (!늦음())
776
- setResultOverlay((cur) => (cur === 결과계산중 ? {} : cur)); };
777
- Promise.resolve(maybeAny).then((data) => { if (data && !늦음())
775
+ const clearPending = () => { if (!isStale())
776
+ setResultOverlay((cur) => (cur === RESULT_PENDING ? {} : cur)); };
777
+ Promise.resolve(maybeAny).then((data) => { if (data && !isStale())
778
778
  setResultOverlay(data);
779
779
  else
780
- 계산중걷기(); }).catch((err) => { if (import.meta.env?.DEV)
781
- console.error('viewResults child handler failed', err); 계산중걷기(); });
780
+ clearPending(); }).catch((err) => { if (import.meta.env?.DEV)
781
+ console.error('viewResults child handler failed', err); clearPending(); });
782
782
  return;
783
783
  }
784
784
  if (typeof externalOnResult === 'function') {
@@ -793,18 +793,18 @@ function TrainingPagePopupInner(props) {
793
793
  setResultOverlay(maybeAny);
794
794
  }
795
795
  else {
796
- setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? 결과계산중 : {});
796
+ setResultOverlay(maybeAny && typeof maybeAny.then === 'function' ? RESULT_PENDING : {});
797
797
  }
798
- const 계산중걷기 = () => { if (!늦음())
799
- setResultOverlay((cur) => (cur === 결과계산중 ? {} : cur)); };
798
+ const clearPending = () => { if (!isStale())
799
+ setResultOverlay((cur) => (cur === RESULT_PENDING ? {} : cur)); };
800
800
  Promise.resolve(maybeAny).then((data) => { try {
801
801
  if (import.meta && import.meta.env && import.meta.env.DEV)
802
802
  console.debug('TrainingPagePopup: externalOnResult resolved data:', data);
803
803
  }
804
- catch (e) { } if (data && !늦음())
804
+ catch (e) { } if (data && !isStale())
805
805
  setResultOverlay(data);
806
806
  else
807
- 계산중걷기(); }).catch((err) => { console.error('viewResults external handler failed', err); 계산중걷기(); });
807
+ clearPending(); }).catch((err) => { console.error('viewResults external handler failed', err); clearPending(); });
808
808
  return;
809
809
  }
810
810
  };
@@ -902,26 +902,26 @@ function TrainingPagePopupInner(props) {
902
902
  };
903
903
  /* 다음 문제로 넘어가면 포커스를 새 문제의 첫 칸으로. '다음 문제' 는 누르는 즉시 비활성이 되어 포커스가 body 로 빠졌다 —
904
904
  키보드 사용자는 매 문제 판 처음부터 Tab 을 다시 밟았다. 포커스가 판 안 다른 곳에 있으면 건드리지 않는다. */
905
- const 앞쪽Ref = useRef(undefined);
905
+ const lastPageRef = useRef(undefined);
906
906
  useEffect(() => {
907
- const 앞 = 앞쪽Ref.current;
908
- 앞쪽Ref.current = effectivePageIndex;
909
- if (앞 === undefined || 앞 === effectivePageIndex)
907
+ const prevPage = lastPageRef.current;
908
+ lastPageRef.current = effectivePageIndex;
909
+ if (prevPage === undefined || prevPage === effectivePageIndex)
910
910
  return;
911
911
  // 키보드로 넘겼을 때만 — 마우스로 누른 사람에게 매 문제 '듣기' 초점 안내가 뜨지 않게
912
- if (!키보드로눌렀나())
912
+ if (!wasKeyboardPress())
913
913
  return;
914
914
  const panel = panelRef.current;
915
915
  if (!panel)
916
916
  return;
917
917
  const active = document.activeElement;
918
- const 잃음 = !active || active === document.body || !panel.contains(active) || active.disabled === true;
919
- if (!잃음)
918
+ const lostFocus = !active || active === document.body || !panel.contains(active) || active.disabled === true;
919
+ if (!lostFocus)
920
920
  return;
921
- const 첫칸 = panel.querySelector(`.training-steps ${FOCUSABLE_SELECTOR.split(', ').join(', .training-steps ')}`)
921
+ const firstCell = panel.querySelector(`.training-steps ${FOCUSABLE_SELECTOR.split(', ').join(', .training-steps ')}`)
922
922
  ?? Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).find((el) => !el.closest('[aria-hidden="true"]'));
923
923
  try {
924
- 첫칸?.focus();
924
+ firstCell?.focus();
925
925
  }
926
926
  catch (e) { }
927
927
  }, [effectivePageIndex]);
@@ -948,17 +948,17 @@ function TrainingPagePopupInner(props) {
948
948
  *
949
949
  * 세대로 **판마다 한 번**만 적는다(다시하기는 새 판이라 또 적는다).
950
950
  */
951
- const 적은판Ref = useRef(-1);
952
- const 판끝남적기 = useCallback(() => {
951
+ const reportedRoundRef = useRef(-1);
952
+ const reportRoundDone = useCallback(() => {
953
953
  if (!typeKey)
954
954
  return undefined;
955
955
  const ro = resultOverlay;
956
- const { results, correct: finalCorrect, total } = 판결과셈(resultOverlay);
957
- const 결과 = { correct: finalCorrect, total, attempt: 앞판들Ref.current.length + 1, previousAttempts: [...앞판들Ref.current] };
956
+ const { results, correct: finalCorrect, total } = countRoundResult(resultOverlay);
957
+ const result = { correct: finalCorrect, total, attempt: previousRoundsRef.current.length + 1, previousAttempts: [...previousRoundsRef.current] };
958
958
  /* 이미 이 판을 적었으면 **값만 돌려준다** — 닫을 때 `onComplete` 가 그 값을 받아야 한다 */
959
- if (적은판Ref.current === 판번호Ref.current)
960
- return 결과;
961
- 적은판Ref.current = 판번호Ref.current;
959
+ if (reportedRoundRef.current === roundNoRef.current)
960
+ return result;
961
+ reportedRoundRef.current = roundNoRef.current;
962
962
  /* 문항 하나하나를 함께 남긴다 — 여기서 접으면 어느 낱말을 틀렸는지 되찾을 길이 없다.
963
963
  '음소별 오류'(08-26 회의)가 이걸 원재료로 쓴다. */
964
964
  logTrainingCompletion({
@@ -974,39 +974,39 @@ function TrainingPagePopupInner(props) {
974
974
  dailyTotal: dailyTotal ?? null,
975
975
  });
976
976
  try {
977
- onRoundFinished?.(결과);
977
+ onRoundFinished?.(result);
978
978
  }
979
979
  catch (e) { /* 알리는 쪽의 실패로 적는 일을 막지 않는다 */ }
980
- return 결과;
980
+ return result;
981
981
  }, [typeKey, entryPoint, dailyTotal, itemRef, resultOverlay, onRoundFinished]);
982
982
  /* **결과창이 서면 적는다** — 닫기를 안 기다린다.
983
983
  결과를 세우는 자리가 넷이라(갈래·어린이·자식 손잡이·바깥 손잡이) 그 넷에 각각 적으면
984
984
  **한 자리가 안 따라간다.** 세운 값을 보고 여기 한 곳에서 적는다.
985
985
  `결과계산중`·빈 결과는 아직 사실이 아니다 — 적지 않는다. */
986
986
  useEffect(() => {
987
- if (!resultOverlay || resultOverlay === 결과계산중)
987
+ if (!resultOverlay || resultOverlay === RESULT_PENDING)
988
988
  return;
989
989
  if (resultOverlay?.isEmpty)
990
990
  return;
991
991
  /* **셀 것이 있어야 적는다.** 결과가 `{}` 로 서는 길이 있다(자식이 빈손을 주거나 늦게
992
992
  실패했을 때) — 그것을 판이 끝난 것으로 적으면 `0/0` 짜리 기록이 쌓인다.
993
993
  그 꼴에서도 **닫을 때는 전과 같이 적힌다**(아래 그물) — 여기서 앞당기지만 않는다. */
994
- if (!((판결과셈(resultOverlay).total ?? 0) > 0))
994
+ if (!((countRoundResult(resultOverlay).total ?? 0) > 0))
995
995
  return;
996
- 판끝남적기();
997
- }, [resultOverlay, 판끝남적기]);
996
+ reportRoundDone();
997
+ }, [resultOverlay, reportRoundDone]);
998
998
  const handleResultDone = useCallback(() => {
999
999
  /* 여기서도 한 번 부른다 — **그물이다.** 위 효과가 못 돈 꼴(결과가 빈 채로 닫는 길 등)에서도
1000
1000
  적히게 두되, 이미 적었으면 세대가 막아 **두 번 안 적힌다.** */
1001
- const completeResult = 판끝남적기();
1002
- 결과창닫기();
1001
+ const completeResult = reportRoundDone();
1002
+ closeResultOverlay();
1003
1003
  scheduleClose(() => {
1004
1004
  if (typeof onComplete === 'function')
1005
1005
  onComplete(completeResult);
1006
1006
  if (typeof onBack === 'function')
1007
1007
  onBack();
1008
1008
  });
1009
- }, [onComplete, onBack, 판끝남적기, setResultOverlay, scheduleClose]);
1009
+ }, [onComplete, onBack, reportRoundDone, setResultOverlay, scheduleClose]);
1010
1010
  const handleNoticeClose = useCallback(() => setNoticeMessage(''), [setNoticeMessage]);
1011
1011
  const handleCloseBack = useCallback(() => {
1012
1012
  scheduleClose(() => { if (typeof onBack === 'function')
@@ -1027,11 +1027,11 @@ function TrainingPagePopupInner(props) {
1027
1027
  onReplay: () => {
1028
1028
  // 다시 푸는 판의 소요 시간은 지금부터 — 마운트 때 한 번만 찍어 앞 판 시간·결과 창을 보던 시간까지 섞였다(60초가 660초)
1029
1029
  completionStartRef.current = Date.now();
1030
- if (resultOverlay !== 결과계산중) {
1031
- const { correct, total } = 판결과셈(resultOverlay);
1032
- 앞판들Ref.current.push({ correct, total });
1030
+ if (resultOverlay !== RESULT_PENDING) {
1031
+ const { correct, total } = countRoundResult(resultOverlay);
1032
+ previousRoundsRef.current.push({ correct, total });
1033
1033
  }
1034
- 결과창닫기();
1034
+ closeResultOverlay();
1035
1035
  setReplayCount((n) => n + 1);
1036
1036
  if (hasScenario) {
1037
1037
  setFlowState(createScenarioFlowState());
@@ -1055,7 +1055,7 @@ function TrainingPagePopupInner(props) {
1055
1055
  catch (e) { }
1056
1056
  },
1057
1057
  onClose: handleResultDone,
1058
- pending: resultOverlay === 결과계산중,
1058
+ pending: resultOverlay === RESULT_PENDING,
1059
1059
  })), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content page-popup__content--center${props.contentFull ? ' page-popup__content--full' : ''}`, children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { htmlType: "button", variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
1060
1060
  const portalContent = (_jsx(NoisePlayerContext.Provider, { value: noiseContextValue, children: jsx }));
1061
1061
  if (typeof document !== 'undefined' && document.body)
@@ -20,9 +20,9 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
20
20
  const mobileRef = useRef(null);
21
21
  const noisePlayer = useNoisePlayer();
22
22
  /* 쪽이 없으면(0) 쪽 이동만 숨기고 끝내는 단추는 둔다 — 전에는 0 === -1 이라 단추가 하나도 없어 판을 끝낼 수 없었다 */
23
- const 쪽있음 = pageCount > 0;
24
- const useNextBtn = 쪽있음 && pageIndex < pageCount - 1;
25
- const useResultBtn = !쪽있음 || pageIndex >= pageCount - 1;
23
+ const hasPages = pageCount > 0;
24
+ const useNextBtn = hasPages && pageIndex < pageCount - 1;
25
+ const useResultBtn = !hasPages || pageIndex >= pageCount - 1;
26
26
  const isBtnActive = stepIndex === Math.max(0, stepCount);
27
27
  // 노이즈 설정 버튼이 비활성화되면 열려 있던 팝업을 닫는 의도된 동기화다
28
28
  /* eslint-disable react-hooks/set-state-in-effect */
@@ -65,35 +65,35 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
65
65
  굴려서 꺼낼 수도 없었다(말귀 실측 2026-09-22: 훈련을 이어갈 수 없다). 폰에서는 토글 단추
66
66
  **위에 떠서** 보인다(사용자 지시). 데스크톱은 그대로 바닥 줄에 나란히 선다 — 거기서는
67
67
  자리가 넉넉하다(1659px 창에서 왼쪽 묶음 434px). */
68
- const 폰 = useMediaQuery('(max-width: 767px)');
69
- const 미터토글ref = useRef(null);
70
- const [미터자리, set미터자리] = useState(null);
68
+ const isPhone = useMediaQuery('(max-width: 767px)');
69
+ const meterToggleRef = useRef(null);
70
+ const [meterRect, setMeterRect] = useState(null);
71
71
  const handleToggleMeters = useCallback(() => {
72
72
  setShowMeters(prev => {
73
- const 다음 = !prev;
74
- set미터자리(다음 ? (미터토글ref.current?.getBoundingClientRect() ?? null) : null);
75
- return 다음;
73
+ const next = !prev;
74
+ setMeterRect(next ? (meterToggleRef.current?.getBoundingClientRect() ?? null) : null);
75
+ return next;
76
76
  });
77
77
  }, []);
78
- const 미터닫기 = useCallback(() => { setShowMeters(false); set미터자리(null); }, []);
78
+ const closeMeters = useCallback(() => { setShowMeters(false); setMeterRect(null); }, []);
79
79
  /* **결과 창이 뜨면 미터를 끈다**(사용자 지시 2026-09-22). 결과를 보는 동안 미터가 떠 있을
80
80
  까닭이 없고, 창을 닫고 다음 판을 시작할 때도 깨끗한 자리에서 시작하는 편이 낫다.
81
81
  아래 설정 창 동기화와 같은 결이다 — 의도한 동기화라 set-state-in-effect 를 연다. */
82
82
  /* eslint-disable-next-line react-hooks/set-state-in-effect */
83
83
  useEffect(() => { if (resultOpen) {
84
84
  setShowMeters(false);
85
- set미터자리(null);
85
+ setMeterRect(null);
86
86
  } }, [resultOpen]);
87
- const 미터들 = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }));
87
+ const meters = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }));
88
88
  /* 설정 창은 판 밖(body)으로 포털된다 — 열리면 초점을 창 첫 칸으로, 닫히면 연 단추로(useFloatingFocus 참고) */
89
- const 창열림 = useNoiseSettingBtn && open;
90
- useFloatingFocus(창열림, () => (typeof document !== 'undefined' ? document.querySelector('.noise-settings__header')?.closest('.tooltip-content') ?? null : null), () => {
91
- const 보이는단추 = [desktopRef.current, mobileRef.current]
89
+ const panelOpen = useNoiseSettingBtn && open;
90
+ useFloatingFocus(panelOpen, () => (typeof document !== 'undefined' ? document.querySelector('.noise-settings__header')?.closest('.tooltip-content') ?? null : null), () => {
91
+ const visibleButtons = [desktopRef.current, mobileRef.current]
92
92
  .map((w) => w?.querySelector('button') ?? null)
93
93
  .find((b) => !!b && b.getClientRects().length > 0);
94
- return 보이는단추 ?? desktopRef.current?.querySelector('button') ?? null;
94
+ return visibleButtons ?? desktopRef.current?.querySelector('button') ?? null;
95
95
  });
96
96
  return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick,
97
97
  /* 여닫이 — aria-pressed(selected) 대신 aria-expanded. 보이는 선택 모양은 같은 클래스로 */
98
- className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", shape: "pill", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정') }) }) })] })) : null, useNoiseSettingBtn && (_jsx("div", { ref: 미터토글ref, className: "pp-bottom__meter-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, "aria-expanded": 폰 ? showMeters : undefined, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') }) })), useNoiseSettingBtn && showMeters && !폰 && (_jsx("div", { className: "pp-bottom__meters", children: 미터들 }))] }), useNoiseSettingBtn && showMeters && 폰 && 미터자리 && (_jsx(Tooltip, { anchorRect: 미터자리, onClose: 미터닫기, ariaLabel: t('training.meter.toggle', '오디오 미터 표시'), children: _jsx("div", { className: "pp-bottom__meters pp-bottom__meters--floating", children: 미터들 }) })), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip, hideNoiseRows: hideNoiseRows }))] }));
98
+ className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", shape: "pill", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정') }) }) })] })) : null, useNoiseSettingBtn && (_jsx("div", { ref: meterToggleRef, className: "pp-bottom__meter-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, "aria-expanded": isPhone ? showMeters : undefined, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') }) })), useNoiseSettingBtn && showMeters && !isPhone && (_jsx("div", { className: "pp-bottom__meters", children: meters }))] }), useNoiseSettingBtn && showMeters && isPhone && meterRect && (_jsx(Tooltip, { anchorRect: meterRect, onClose: closeMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시'), children: _jsx("div", { className: "pp-bottom__meters pp-bottom__meters--floating", children: meters }) })), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip, hideNoiseRows: hideNoiseRows }))] }));
99
99
  }