@ihabdevteam/core 0.322.0 → 0.324.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 (198) hide show
  1. package/CHANGELOG.md +557 -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 +55 -17
  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/Icon.js +2 -2
  24. package/dist/packages/core/src/components/ItemActions.js +16 -16
  25. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  26. package/dist/packages/core/src/components/LineChart.js +24 -24
  27. package/dist/packages/core/src/components/Loader.js +5 -5
  28. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  29. package/dist/packages/core/src/components/PageTitle.js +2 -2
  30. package/dist/packages/core/src/components/Pagination.js +4 -4
  31. package/dist/packages/core/src/components/Panel.js +2 -2
  32. package/dist/packages/core/src/components/PanelRow.js +6 -13
  33. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  34. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  35. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  36. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  37. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  38. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
  39. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  40. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
  41. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  42. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  43. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  44. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  45. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  46. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  47. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  48. package/dist/packages/core/src/components/Rating.js +20 -20
  49. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  50. package/dist/packages/core/src/components/RingChart.js +5 -5
  51. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  52. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  53. package/dist/packages/core/src/components/SideNav.js +37 -37
  54. package/dist/packages/core/src/components/Slider.js +2 -2
  55. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  56. package/dist/packages/core/src/components/Table.js +257 -98
  57. package/dist/packages/core/src/components/Tabs.js +12 -12
  58. package/dist/packages/core/src/components/TextField.js +34 -34
  59. package/dist/packages/core/src/components/Toast.js +6 -6
  60. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  61. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  62. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  63. package/dist/packages/core/src/components/TopBanner.js +28 -28
  64. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  65. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  66. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  67. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  68. package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
  69. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  70. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  71. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  72. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  73. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  74. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
  75. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  76. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
  77. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  78. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  79. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  80. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
  81. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  82. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  83. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  84. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  85. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  86. package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
  87. package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
  88. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  89. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  90. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  91. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  92. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  93. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  94. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  95. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  96. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  97. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  98. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  99. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  100. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  101. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
  102. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  103. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  104. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  105. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  106. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  107. package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
  108. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  109. package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
  110. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  111. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  112. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  113. package/dist/packages/core/src/components/WordTable.js +130 -130
  114. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  115. package/dist/packages/core/src/components/barChartData.js +49 -29
  116. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  117. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  118. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  119. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  120. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  121. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  122. package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
  123. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  124. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  125. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  126. package/dist/packages/core/src/hooks/inputModality.js +13 -13
  127. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  128. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  129. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  130. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  131. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  132. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  133. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  134. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  135. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  136. package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
  137. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  138. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  139. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  140. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  141. package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
  142. package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
  143. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  144. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  145. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  146. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  147. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
  148. package/dist/packages/core/src/i18n/locales/en.json +14 -2
  149. package/dist/packages/core/src/i18n/locales/ko.json +13 -1
  150. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  151. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  152. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  153. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  154. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  155. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  156. package/dist/packages/core/src/utils/clipboard.js +3 -3
  157. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  158. package/dist/packages/core/src/utils/date.js +12 -12
  159. package/dist/packages/core/src/utils/devWarn.js +4 -4
  160. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  161. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  162. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  163. package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
  164. package/dist/packages/core/src/utils/format.js +4 -4
  165. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  166. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  167. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  168. package/dist/packages/core/src/utils/navScroll.js +2 -2
  169. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  170. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  171. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  172. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  173. package/dist/packages/core/src/utils/phonemePairSearch.js +129 -105
  174. package/dist/packages/core/src/utils/phonemePairSearchTypes.d.ts +2 -0
  175. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  176. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  177. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  178. package/dist/packages/core/src/utils/safeHref.js +4 -4
  179. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  180. package/dist/packages/core/src/utils/speak.js +12 -12
  181. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  182. package/dist/packages/core/src/utils/textSort.js +38 -48
  183. package/dist/packages/core/src/utils/theme.js +3 -3
  184. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  185. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  186. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +1 -1
  187. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +65 -45
  188. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  189. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  190. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  191. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  192. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  193. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  194. package/dist/packages/core/src/utils/wornDate.js +14 -14
  195. package/dist/packages/core/src/version.d.ts +1 -1
  196. package/dist/packages/core/src/version.js +1 -1
  197. package/dist/utils.cjs.js +4 -4
  198. package/package.json +1 -1
@@ -38,13 +38,13 @@ export default function Popup(props) {
38
38
  if (inline)
39
39
  return;
40
40
  if (!title && !ariaLabel && import.meta.env?.DEV) {
41
- const 본문 = panelRef.current?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 60) ?? '';
42
- const 표시 = [
41
+ const bodyText = panelRef.current?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 60) ?? '';
42
+ const marks = [
43
43
  className ? `class="${className}"` : null,
44
- 본문 ? `본문 "${본문}${본문.length >= 60 ? '…' : ''}"` : null,
44
+ bodyText ? `본문 "${bodyText}${bodyText.length >= 60 ? '…' : ''}"` : null,
45
45
  ].filter(Boolean).join(' · ');
46
46
  console.warn(`[Popup] 제목도 ariaLabel도 없어 다이얼로그에 접근명이 없습니다 — ariaLabel을 주세요.` +
47
- (표시 ? ` (${표시})` : ' (단서 없음 — 빈 팝업)'));
47
+ (marks ? ` (${marks})` : ' (단서 없음 — 빈 팝업)'));
48
48
  }
49
49
  }, [inline, title, ariaLabel, className]);
50
50
  // resetScrollKey가 바뀌면(탭/모드 전환 등) 본문 스크롤을 맨 위로 되돌린다 —
@@ -114,59 +114,59 @@ export default function Popup(props) {
114
114
  /* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 약속을 돌려준 콜백이 끝난 뒤에도 이 창이 그대로 있으면 되살린다** —
115
115
  전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
116
116
  없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
117
- const 살아있음Ref = useRef(true);
117
+ const aliveRef = useRef(true);
118
118
  /* '동작 줄이기' 면 닫기 대기를 0 으로 — CSS 는 움직임을 없애지만 이 타이머는 못 줄여, 움직임 없이 260ms 늦게 사라지며 투명한 막이 누름을 가로챘다 */
119
- const 동작줄이기 = usePrefersReducedMotion();
119
+ const reducedMotion = usePrefersReducedMotion();
120
120
  /* 닫기(X·ESC·바탕)가 걸어 둔 타이머 — 닫는 애니메이션(260ms) 안에 부모가 창을 치우면 **떠난 뒤에 onClose** 가 불렸다(닫으며
121
121
  다음 창을 여는 흐름에서 늦은 setX(null) 이 새 창을 닫을 수 있다). 떠날 때 걷는다. 소비자가 넘긴 animateThen(cb) 은
122
122
  "삭제·저장 확정" 일 수 있어 걷지 않는다(결정 2026-09-15: 떠난 뒤에도 부른다 — PopupOptions JSDoc). */
123
- const 닫기타이머Ref = useRef(null);
123
+ const closeTimerRef = useRef(null);
124
124
  useEffect(() => {
125
- 살아있음Ref.current = true;
125
+ aliveRef.current = true;
126
126
  return () => {
127
- 살아있음Ref.current = false;
128
- if (닫기타이머Ref.current) {
129
- clearTimeout(닫기타이머Ref.current);
130
- 닫기타이머Ref.current = null;
127
+ aliveRef.current = false;
128
+ if (closeTimerRef.current) {
129
+ clearTimeout(closeTimerRef.current);
130
+ closeTimerRef.current = null;
131
131
  }
132
132
  };
133
133
  }, []);
134
- const 되살리기 = useCallback(() => {
135
- if (!살아있음Ref.current || !closingRef.current)
134
+ const restore = useCallback(() => {
135
+ if (!aliveRef.current || !closingRef.current)
136
136
  return;
137
137
  closingRef.current = false;
138
138
  setClosing(false);
139
139
  }, []);
140
- const animateThen = useCallback((cb, 닫기뿐 = false) => {
140
+ const animateThen = useCallback((cb, closeOnly = false) => {
141
141
  /* 이미 떠난 창을 닫으려는 부름(ConfirmPopup 의 onConfirm 안에서 부모가 창을 치운 뒤 closeWithAnimation)은 받지 않는다 —
142
142
  정리가 끝난 뒤 걸린 타이머라 걷히지 않고 260ms 뒤 **취소 경로의 onClose** 가 불렸다(kscan 이 closeOnConfirm=false 로 우회). */
143
- if (닫기뿐 && !살아있음Ref.current)
143
+ if (closeOnly && !aliveRef.current)
144
144
  return;
145
145
  if (closingRef.current)
146
146
  return;
147
147
  closingRef.current = true;
148
148
  setClosing(true);
149
149
  const id = setTimeout(() => {
150
- if (닫기타이머Ref.current === id)
151
- 닫기타이머Ref.current = null;
152
- let 결과;
150
+ if (closeTimerRef.current === id)
151
+ closeTimerRef.current = null;
152
+ let result;
153
153
  try {
154
- 결과 = cb ? cb() : undefined;
154
+ result = cb ? cb() : undefined;
155
155
  }
156
156
  catch (e) {
157
- 되살리기();
157
+ restore();
158
158
  return;
159
159
  }
160
160
  /* 약속을 돌려준 콜백만 **끝난 뒤에도 남아 있으면** 되살린다. 동기 콜백(undefined)은 되살리지 않는다 — 부모가 저장을
161
161
  따로 기다렸다 닫는 흔한 꼴(onSave 를 부르고 약속은 안 돌려줌)에서 기다리는 사이 창이 다시 보이고 저장 단추가 다시
162
162
  눌려 두 번 저장됐다. 저장 실패에 창을 되살리고 싶으면 콜백이 그 약속을 돌려주면 된다. */
163
- if (결과 && typeof 결과.then === 'function') {
164
- 결과.then(() => { setTimeout(되살리기, 120); }, 되살리기);
163
+ if (result && typeof result.then === 'function') {
164
+ result.then(() => { setTimeout(restore, 120); }, restore);
165
165
  }
166
- }, 동작줄이기 ? 0 : ANIM_MS);
167
- if (닫기뿐)
168
- 닫기타이머Ref.current = id;
169
- }, [되살리기, 동작줄이기]);
166
+ }, reducedMotion ? 0 : ANIM_MS);
167
+ if (closeOnly)
168
+ closeTimerRef.current = id;
169
+ }, [restore, reducedMotion]);
170
170
  const closeWithAnimation = useCallback(() => {
171
171
  if (closeDisabled)
172
172
  return;
@@ -43,17 +43,17 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
43
43
  existingNames.some(n => n.trim().toLowerCase() === name.trim().toLowerCase());
44
44
  /* 저장은 **한 번만** — 저장 단추의 닫힘 애니메이션(260ms) 동안 이름칸에서 Enter 를 치면 onSave 가 또 불렸다(Safari 는 단추를
45
45
  눌러도 포커스가 칸에 남아 쉽게 난다). */
46
- const 저장함 = useRef(false);
46
+ const savedRef = useRef(false);
47
47
  const handleSave = useCallback((animateThen) => {
48
- if (저장함.current)
48
+ if (savedRef.current)
49
49
  return;
50
- 저장함.current = true;
50
+ savedRef.current = true;
51
51
  animateThen(() => onSave(name.trim(), description.trim() || undefined));
52
52
  }, [name, description, onSave]);
53
53
  const handleEnter = useCallback(() => {
54
- if (저장함.current || !name.trim() || isDuplicateName)
54
+ if (savedRef.current || !name.trim() || isDuplicateName)
55
55
  return;
56
- 저장함.current = true;
56
+ savedRef.current = true;
57
57
  onSave(name.trim(), description.trim() || undefined);
58
58
  }, [name, description, isDuplicateName, onSave]);
59
59
  return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
@@ -60,8 +60,8 @@ function resolveDecision(it) {
60
60
  export default function TrainingResultPopup(props) {
61
61
  const { score, contextsLength, results = [], subtitle, onReplay, onClose, inline = false, pending = false } = props || {};
62
62
  const { t } = useTranslation();
63
- const 까닭Id = useId();
64
- const 계산중이면막기 = useCallback(() => !pending, [pending]);
63
+ const reasonId = useId();
64
+ const blockWhilePending = useCallback(() => !pending, [pending]);
65
65
  // 축하 컨페티는 정답률 50% 초과일 때만 — 낮은 점수(≤50%)에 축하가 나가지 않게 한다(언어치료 맥락).
66
66
  useEffect(() => {
67
67
  const total = contextsLength ?? 0;
@@ -114,7 +114,7 @@ export default function TrainingResultPopup(props) {
114
114
  주는 이름이 갈라진다. */
115
115
  ariaLabel: t('training.result.title', '훈련을 모두 마쳤습니다.'), className: "popup--training-result",
116
116
  // 결과 계산 중에는 Esc 로도 완료(닫기)하지 않는다 — '완료' 단추와 같은 까닭
117
- beforeClose: 계산중이면막기, footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [pending && _jsx("p", { id: 까닭Id, className: "trp-actions__reason caption-1", role: "status", children: t('training.result.pendingDone', '결과를 계산하는 중이라 아직 완료할 수 없습니다.') }), _jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation,
117
+ beforeClose: blockWhilePending, footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [pending && _jsx("p", { id: reasonId, className: "trp-actions__reason caption-1", role: "status", children: t('training.result.pendingDone', '결과를 계산하는 중이라 아직 완료할 수 없습니다.') }), _jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation,
118
118
  /* 막되 초점은 남긴다(onDisabledClick 이 있으면 native disabled 대신 aria-disabled) — 키보드·낭독기가 까닭을 들을 수 있게 */
119
- disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? 까닭Id : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
119
+ disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? reasonId : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
120
120
  }
@@ -5,8 +5,8 @@
5
5
  import i18n from 'i18next';
6
6
  import { getTodayKst } from '../../utils/date';
7
7
  /** i18n 이 아직 안 섰으면(초기화 전 `i18n.t` 는 undefined) 한국어 기본값을 직접 채운다. */
8
- function 번역됐거나(s, 기본, vars) {
9
- return typeof s === 'string' && s ? s : 기본.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k]));
8
+ function translatedOr(s, fallback, vars) {
9
+ return typeof s === 'string' && s ? s : fallback.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k]));
10
10
  }
11
11
  export function getDayOptions(year, month, daySuffix) {
12
12
  const days = new Date(Number(year), Number(month), 0).getDate();
@@ -16,16 +16,16 @@ export function getDayOptions(year, month, daySuffix) {
16
16
  });
17
17
  }
18
18
  /** 'YYYY.MM.DD' · 'YYYY-MM-DD' · 'YYYYMMDD' 를 숫자 셋으로. 없는 날(2/30 등)이면 null. */
19
- function 날짜쪼개기(s) {
20
- const 숫자 = s.trim().replace(/[.-]/g, '');
21
- if (!/^\d{8}$/.test(숫자))
19
+ function splitDate(s) {
20
+ const digits = s.trim().replace(/[.-]/g, '');
21
+ if (!/^\d{8}$/.test(digits))
22
22
  return null;
23
- const y = Number(숫자.slice(0, 4)), m = Number(숫자.slice(4, 6)), d = Number(숫자.slice(6, 8));
24
- if (m < 1 || m > 12 || d < 1 || d > 말일(y, m))
23
+ const y = Number(digits.slice(0, 4)), m = Number(digits.slice(4, 6)), d = Number(digits.slice(6, 8));
24
+ if (m < 1 || m > 12 || d < 1 || d > lastDayOf(y, m))
25
25
  return null;
26
26
  return { y, m, d };
27
27
  }
28
- const 말일 = (y, m) => new Date(y, m, 0).getDate();
28
+ const lastDayOf = (y, m) => new Date(y, m, 0).getDate();
29
29
  /**
30
30
  * 만 나이 — "N세 M개월" 을 `ageStr` 로 적는다. 못 읽으면 '-'.
31
31
  *
@@ -44,15 +44,15 @@ const 말일 = (y, m) => new Date(y, m, 0).getDate();
44
44
  export function calcAge(birthDate, ageStr) {
45
45
  if (!birthDate)
46
46
  return '-';
47
- const b = 날짜쪼개기(birthDate);
48
- const t = 날짜쪼개기(getTodayKst());
47
+ const b = splitDate(birthDate);
48
+ const t = splitDate(getTodayKst());
49
49
  if (!b || !t)
50
50
  return '-';
51
51
  let years = t.y - b.y;
52
52
  let months = t.m - b.m;
53
53
  // 일(day)까지 비교해야 함 — 월만 비교하면 생일이 아직 안 지난 달엔 나이가 1살 많게 나온다.
54
54
  // 이번 달에 그 날이 없으면 말일이 그날이다.
55
- if (t.d < Math.min(b.d, 말일(t.y, t.m)))
55
+ if (t.d < Math.min(b.d, lastDayOf(t.y, t.m)))
56
56
  months--;
57
57
  if (months < 0) {
58
58
  years--;
@@ -78,17 +78,17 @@ export function visitItemKey(v) {
78
78
  */
79
79
  export function formatDuration(sec, durationStr) {
80
80
  // 초 단위로 반올림한 뒤 나눈다 — 90.5 가 "1분 30.5초", 59.999 가 "59.999초" 로 찍혔다
81
- const 초 = Number.isFinite(sec) ? Math.round(sec) : 0;
82
- if (!초 || 초 <= 0)
81
+ const seconds = Number.isFinite(sec) ? Math.round(sec) : 0;
82
+ if (!seconds || seconds <= 0)
83
83
  return '-';
84
- const m = Math.floor(초 / 60);
85
- const s = 초 % 60;
84
+ const m = Math.floor(seconds / 60);
85
+ const s = seconds % 60;
86
86
  if (durationStr)
87
87
  return durationStr(m, s);
88
88
  // 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
89
89
  if (m > 0 && s > 0)
90
- return 번역됐거나(i18n.t('customers.duration.minSec', { m, s, defaultValue: '{{m}}분 {{s}}초' }), '{{m}}분 {{s}}초', { m, s });
90
+ return translatedOr(i18n.t('customers.duration.minSec', { m, s, defaultValue: '{{m}}분 {{s}}초' }), '{{m}}분 {{s}}초', { m, s });
91
91
  if (m > 0)
92
- return 번역됐거나(i18n.t('customers.duration.min', { m, defaultValue: '{{m}}분' }), '{{m}}분', { m });
93
- return 번역됐거나(i18n.t('customers.duration.sec', { s, defaultValue: '{{s}}초' }), '{{s}}초', { s });
92
+ return translatedOr(i18n.t('customers.duration.min', { m, defaultValue: '{{m}}분' }), '{{m}}분', { m });
93
+ return translatedOr(i18n.t('customers.duration.sec', { s, defaultValue: '{{s}}초' }), '{{s}}초', { s });
94
94
  }
@@ -37,12 +37,12 @@ import ConfirmPopup from './ConfirmPopup';
37
37
  export function useDiscardGuard(isDirty, onClose, labels = {}) {
38
38
  const { t } = useTranslation();
39
39
  const [confirming, setConfirming] = useState(false);
40
- const 통과 = useRef(false);
41
- const allowClose = useCallback(() => { 통과.current = true; }, []);
40
+ const bypassRef = useRef(false);
41
+ const allowClose = useCallback(() => { bypassRef.current = true; }, []);
42
42
  const beforeClose = useCallback(() => {
43
43
  /* 한 번 쓰고 되돌린다 — 안 되돌리면 그 뒤로 영영 안 묻는다 */
44
- if (통과.current) {
45
- 통과.current = false;
44
+ if (bypassRef.current) {
45
+ bypassRef.current = false;
46
46
  return true;
47
47
  }
48
48
  if (isDirty) {
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
4
4
  import useMediaQuery from '../hooks/useMediaQuery';
5
5
  import './ProgressIndicator.css';
6
6
  /** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
7
- const 보이지않게 = {
7
+ const srOnly = {
8
8
  position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
9
9
  clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
10
10
  };
@@ -48,13 +48,13 @@ export default function ProgressIndicator({ max, index, inactiveBg, inactiveColo
48
48
  묶고 이름표에 자리를 적는다. 화면이 스스로 넘어가는 자리에서는
49
49
  announce 로 role="status" 를 켠다. */
50
50
  if (variant === 'dot') {
51
- return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: 보이지않게, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
51
+ return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: srOnly, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
52
52
  }
53
53
  /* 번호·모바일 모드의 알리는 자리 — 전에는 announce 가 점 모드에서만 먹었다. 목록 곁 형제로 두고 absolute 라 배치에 안 끼어든다. */
54
- const 알림 = announce ? _jsx("span", { role: "status", style: 보이지않게, children: groupLabel }) : null;
54
+ const liveRegion = announce ? _jsx("span", { role: "status", style: srOnly, children: groupLabel }) : null;
55
55
  // 모바일: 현재 단계만 표시 (n / max 형태)
56
56
  if (isMobile) {
57
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "progress-indicator progress-indicator--mobile", style: rootStyle, role: "list", "aria-label": groupLabel, children: _jsxs("span", { role: "listitem", className: "progress-indicator-dot active body-3", children: [idx + 1, " / ", max] }) }), 알림] }));
57
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "progress-indicator progress-indicator--mobile", style: rootStyle, role: "list", "aria-label": groupLabel, children: _jsxs("span", { role: "listitem", className: "progress-indicator-dot active body-3", children: [idx + 1, " / ", max] }) }), liveRegion] }));
58
58
  }
59
- return (_jsxs(_Fragment, { children: [알림, _jsx("div", { className: "progress-indicator", style: rootStyle, role: "list", "aria-label": groupLabel, children: Array.from({ length: max }).map((_, i) => (_jsx("span", { role: "listitem", "aria-current": i === idx ? 'step' : undefined, className: `progress-indicator-dot ${i < idx ? ' done' : i === idx ? ' active' : ''} body-3`, children: i + 1 }, i))) })] }));
59
+ return (_jsxs(_Fragment, { children: [liveRegion, _jsx("div", { className: "progress-indicator", style: rootStyle, role: "list", "aria-label": groupLabel, children: Array.from({ length: max }).map((_, i) => (_jsx("span", { role: "listitem", "aria-current": i === idx ? 'step' : undefined, className: `progress-indicator-dot ${i < idx ? ' done' : i === idx ? ' active' : ''} body-3`, children: i + 1 }, i))) })] }));
60
60
  }
@@ -2,9 +2,9 @@ import i18n from '../../i18n';
2
2
  /* 라벨은 **읽는 때의 언어**로 — 전에는 한국어가 박혀 영어 화면의 배경 고르개(어드민 공지 폼·놀이터)도 한국어였다.
3
3
  배열·항목 모양(key·label·textTone)은 그대로 두고 label 만 읽을 때 사전(`promoBannerBg.<key>`)에서 꺼내는 getter 로 둔다
4
4
  — 모듈을 읽은 순간의 언어로 굳지 않고, `{ ...o }` 로 펼치면 그때의 글자가 담긴다. ko 글자는 전과 같다. */
5
- const 배경 = (key, 한국어, textTone) => ({
5
+ const bg = (key, ko, textTone) => ({
6
6
  key,
7
- get label() { return i18n.t(`promoBannerBg.${key}`, 한국어); },
7
+ get label() { return i18n.t(`promoBannerBg.${key}`, ko); },
8
8
  textTone,
9
9
  });
10
10
  /**
@@ -12,20 +12,20 @@ const 배경 = (key, 한국어, textTone) => ({
12
12
  * @public
13
13
  */
14
14
  export const PROMO_BANNER_BG_OPTIONS = [
15
- 배경('main', '메인 그라디언트', 'light'),
16
- 배경('soft', '소프트', 'dark'),
17
- 배경('sub', '서브 컬러', 'light'),
18
- 배경('deep', '딥 그라디언트', 'light'),
19
- 배경('softSub', '소프트 서브', 'dark'),
20
- 배경('warm', '웜(강조)', 'dark'),
21
- 배경('dark', '다크', 'light'),
22
- 배경('radial', '방사형 그라디언트', 'light'),
23
- 배경('diagonal', '대각선 패턴', 'dark'),
24
- 배경('dots', '도트 패턴', 'dark'),
25
- 배경('outline', '아웃라인', 'dark'),
26
- 배경('success', '성공/완료', 'light'),
27
- 배경('naver', '네이버 블로그 안내', 'light'),
28
- 배경('instagram', '인스타그램 안내', 'light'),
15
+ bg('main', '메인 그라디언트', 'light'),
16
+ bg('soft', '소프트', 'dark'),
17
+ bg('sub', '서브 컬러', 'light'),
18
+ bg('deep', '딥 그라디언트', 'light'),
19
+ bg('softSub', '소프트 서브', 'dark'),
20
+ bg('warm', '웜(강조)', 'dark'),
21
+ bg('dark', '다크', 'light'),
22
+ bg('radial', '방사형 그라디언트', 'light'),
23
+ bg('diagonal', '대각선 패턴', 'dark'),
24
+ bg('dots', '도트 패턴', 'dark'),
25
+ bg('outline', '아웃라인', 'dark'),
26
+ bg('success', '성공/완료', 'light'),
27
+ bg('naver', '네이버 블로그 안내', 'light'),
28
+ bg('instagram', '인스타그램 안내', 'light'),
29
29
  ];
30
30
  import PromoBannerBgMain from './PromoBannerBgMain';
31
31
  import PromoBannerBgSoft from './PromoBannerBgSoft';
@@ -17,16 +17,16 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
17
17
  const [hoverValue, setHoverValue] = useState(null);
18
18
  /* 고르는 모드는 별 하나 단위라 **반올림한 값**으로 그리고 옮긴다 — 소수 value(3.4)를 그대로 두면 별 셋 반쯤이 칠해지는데
19
19
  어느 라디오도 골라지지 않았고(aria-checked 없음), 화살표는 4.4·2.4 로 옮겨 가 정수 별과 어긋났다. 읽기 전용은 소수 그대로. */
20
- const 값 = isReadOnly ? value : (Number.isFinite(value) ? Math.round(value) : 0);
21
- const displayValue = hoverValue ?? 값;
20
+ const current = isReadOnly ? value : (Number.isFinite(value) ? Math.round(value) : 0);
21
+ const displayValue = hoverValue ?? current;
22
22
  const iconSize = ICON_SIZE[size];
23
23
  /* 값이 바뀔 때만 onChange(2026-09-15 결정) — 소수 value 3.4 에서 별 3 을 누르면 3 으로 바뀌는 것이라 부른다 */
24
- const 알림 = (다음) => { if (다음 !== value)
25
- onChange?.(다음); };
24
+ const notify = (next) => { if (next !== value)
25
+ onChange?.(next); };
26
26
  const handleClick = (star) => {
27
27
  if (isReadOnly)
28
28
  return;
29
- 알림(star);
29
+ notify(star);
30
30
  };
31
31
  const handleMouseEnter = (star) => {
32
32
  if (isReadOnly)
@@ -41,38 +41,38 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
41
41
  이라 읽어 주는데 화살표가 죽어 있다. 실측으로 별 다섯이 전부 Tab 자리였다.
42
42
  별점은 순서가 뜻을 지니므로 **돌지 않는다** — 1에서 왼쪽, 5에서 오른쪽은 그 자리에
43
43
  머문다. 아직 안 골랐으면 첫 화살표가 1을 고른다. */
44
- const 그룹 = useRef(null);
45
- const 옮기기 = (다음) => {
46
- const 잡은값 = Math.min(max, Math.max(1, 다음));
47
- 알림(잡은값);
48
- const 칸 = Array.from(그룹.current?.querySelectorAll('[data-star]') ?? [])
49
- .find((el) => el.dataset.star === String(잡은값));
50
- 칸?.focus();
44
+ const groupRef = useRef(null);
45
+ const moveTo = (next) => {
46
+ const clamped = Math.min(max, Math.max(1, next));
47
+ notify(clamped);
48
+ const stars = Array.from(groupRef.current?.querySelectorAll('[data-star]') ?? [])
49
+ .find((el) => el.dataset.star === String(clamped));
50
+ stars?.focus();
51
51
  };
52
- const 키 = (e) => {
52
+ const onKeyDown = (e) => {
53
53
  if (isReadOnly)
54
54
  return;
55
55
  switch (e.key) {
56
56
  case 'ArrowRight':
57
57
  case 'ArrowUp':
58
- 옮기기((값 || 0) + 1);
58
+ moveTo((current || 0) + 1);
59
59
  break;
60
60
  case 'ArrowLeft':
61
61
  case 'ArrowDown':
62
- 옮기기((값 || 1) - 1);
62
+ moveTo((current || 1) - 1);
63
63
  break;
64
64
  case 'Home':
65
- 옮기기(1);
65
+ moveTo(1);
66
66
  break;
67
67
  case 'End':
68
- 옮기기(max);
68
+ moveTo(max);
69
69
  break;
70
70
  default: return;
71
71
  }
72
72
  e.preventDefault();
73
73
  };
74
74
  /* Tab 자리를 받을 한 칸 — 고른 별, 없으면 첫 별 */
75
- const 탭자리 = 값 >= 1 && 값 <= max ? Math.round(값) : 1;
75
+ const tabValue = current >= 1 && current <= max ? Math.round(current) : 1;
76
76
  const handleMouseLeave = () => {
77
77
  if (isReadOnly)
78
78
  return;
@@ -83,7 +83,7 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
83
83
  대체 글자가 없으면 낭독기에는 빈 자리다 — 눈에는 별 넷 반이 보이는데 아무것도
84
84
  안 읽힌다. 별점을 보여 주는 것이 이 모드의 유일한 일인데 그것이 사라진다.
85
85
  (별을 하나씩 읽히게 하면 "별·별·별…" 이 다섯 번 나올 뿐이라 묶음에 준다.) */
86
- _jsx("div", { className: `rating rating--${size}${isReadOnly ? ' rating--readOnly' : ''} ${className}`.trim(), ref: 그룹, onKeyDown: 키, role: isReadOnly ? 'img' : 'radiogroup', "aria-label": isReadOnly
86
+ _jsx("div", { className: `rating rating--${size}${isReadOnly ? ' rating--readOnly' : ''} ${className}`.trim(), ref: groupRef, onKeyDown: onKeyDown, role: isReadOnly ? 'img' : 'radiogroup', "aria-label": isReadOnly
87
87
  ? t('rating.valueLabel', { value, max })
88
88
  : (ariaLabel ?? t('rating.groupLabel', '별점')), onMouseLeave: handleMouseLeave,
89
89
  /* 터치를 떼거나 취소되면 미리보기를 푼다 */
@@ -99,6 +99,6 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
99
99
  if (isReadOnly) {
100
100
  return (_jsx("span", { className: "rating__item", "aria-hidden": "true", children: starNode }, star));
101
101
  }
102
- return (_jsx("button", { type: "button", className: "rating__item rating__item--interactive", role: "radio", "aria-checked": 값 === star, "aria-label": t('rating.starLabel', { n: star }), "data-star": star, tabIndex: star === 탭자리 ? 0 : -1, onClick: () => handleClick(star), onMouseEnter: () => handleMouseEnter(star), children: starNode }, star));
102
+ return (_jsx("button", { type: "button", className: "rating__item rating__item--interactive", role: "radio", "aria-checked": current === star, "aria-label": t('rating.starLabel', { n: star }), "data-star": star, tabIndex: star === tabValue ? 0 : -1, onClick: () => handleClick(star), onMouseEnter: () => handleMouseEnter(star), children: starNode }, star));
103
103
  }) }));
104
104
  }
@@ -55,19 +55,19 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
55
55
  넘치는데, 이 상자 안에는 포커스 갈 곳이 하나도 없다 — 그러면 키보드만 쓰는
56
56
  사람은 오른쪽 절반을 **영영 못 본다**(WCAG 2.1.1). 실측: 겉 560px 에 안 794px.
57
57
  늘 `tabIndex=0` 을 걸면 안 넘칠 때도 빈 탭 자리가 하나 생기므로 재서 건다. */
58
- const [넘침, set넘침] = useState(false);
58
+ const [overflowing, setOverflowing] = useState(false);
59
59
  useEffect(() => {
60
60
  const el = rootRef.current;
61
61
  if (!el)
62
62
  return;
63
- const 재자 = () => set넘침(el.scrollWidth > el.clientWidth + 1);
64
- 재자();
63
+ const measure = () => setOverflowing(el.scrollWidth > el.clientWidth + 1);
64
+ measure();
65
65
  // ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
66
66
  // 그대로 던져 렌더가 통째로 깨진다. 위에서 한 번은 이미 쟀으므로 추적만 포기한다
67
67
  // (BottomNav·WordTable 과 같은 처리).
68
68
  if (typeof ResizeObserver === 'undefined')
69
69
  return;
70
- const ro = new ResizeObserver(재자);
70
+ const ro = new ResizeObserver(measure);
71
71
  ro.observe(el);
72
72
  return () => ro.disconnect();
73
73
  }, [measured]);
@@ -81,36 +81,36 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
81
81
  const shape = blocks.map((b) => `${b.id}\u0000${b.section ?? ''}\u0000${b.group ?? ''}`).join('\u0001');
82
82
  const pages = measured && measured.shape === shape ? measured.pages : null;
83
83
  /* 자(ruler)의 높이 — 0 에서 크기가 생기면(숨겨져 있다 보이게 됨) 다시 잰다 */
84
- const [자크기, set자크기] = useState(0);
84
+ const [rulerSize, setRulerSize] = useState(0);
85
85
  useEffect(() => {
86
86
  const ruler = rulerRef.current;
87
87
  if (!ruler || typeof ResizeObserver === 'undefined')
88
88
  return;
89
- const ro = new ResizeObserver(() => set자크기(ruler.offsetHeight));
89
+ const ro = new ResizeObserver(() => setRulerSize(ruler.offsetHeight));
90
90
  ro.observe(ruler);
91
91
  return () => ro.disconnect();
92
92
  }, []);
93
93
  /* **내용 높이가 바뀌면 다시 잰다** — 짜임(id·section·group)이 같아도 메모가 길어지거나 글꼴·이미지가 늦게 와 블록 높이가 달라지면
94
94
  예전 나눔 그대로 장이 넘쳤다(소리방향성은 id 에 메모 길이를 넣어 짜임을 바꾸는 우회를 썼다 — 그 길도 그대로 된다).
95
95
  재기 사본 블록마다 ResizeObserver 를 걸고, 여러 번 울려도 다시 나눔은 rAF 한 번으로 모은다. 없는 환경에서는 지금처럼. */
96
- const [다시재기, set다시재기] = useState(0);
96
+ const [remeasureTick, setRemeasureTick] = useState(0);
97
97
  useEffect(() => {
98
98
  const el = measureRef.current;
99
99
  if (!el || typeof ResizeObserver === 'undefined')
100
100
  return;
101
- let 틀 = null;
102
- const 미루기 = typeof requestAnimationFrame === 'function'
101
+ let frameId = null;
102
+ const defer = typeof requestAnimationFrame === 'function'
103
103
  ? (f) => requestAnimationFrame(f)
104
104
  : (f) => setTimeout(f, 16);
105
- const 거두기 = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : (id) => clearTimeout(id);
105
+ const undefer = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : (id) => clearTimeout(id);
106
106
  const ro = new ResizeObserver(() => {
107
- if (틀 != null)
107
+ if (frameId != null)
108
108
  return;
109
- 틀 = 미루기(() => { 틀 = null; set다시재기((n) => n + 1); });
109
+ frameId = defer(() => { frameId = null; setRemeasureTick((n) => n + 1); });
110
110
  });
111
111
  Array.from(el.children).forEach((c) => ro.observe(c));
112
- return () => { ro.disconnect(); if (틀 != null)
113
- 거두기(틀); };
112
+ return () => { ro.disconnect(); if (frameId != null)
113
+ undefer(frameId); };
114
114
  }, [shape]);
115
115
  useLayoutEffect(() => {
116
116
  const el = measureRef.current;
@@ -124,13 +124,13 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
124
124
  /* 높이는 **소수 그대로** 잰다 — offsetHeight 는 정수로 반올림해, 소수 높이 블록이 모이면 반올림 합은 예산과 같은데 실제 장은
125
125
  종이보다 3px 넘쳤고(mm 규격의 자 1016.69px 는 1017 로 올림) 인쇄에서는 그 1px 로 쪽이 하나 더 생겼다(Chromium PDF 확인).
126
126
  rect 가 0 인 환경(레이아웃 없는 시험)에서는 offsetHeight 로 물러선다. */
127
- const 높이 = (node) => node.getBoundingClientRect().height || node.offsetHeight;
127
+ const heightOf = (node) => node.getBoundingClientRect().height || node.offsetHeight;
128
128
  const chromeOf = new Map();
129
129
  chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
130
- chromeOf.set(node.dataset.chromeSection ?? '', 높이(node));
130
+ chromeOf.set(node.dataset.chromeSection ?? '', heightOf(node));
131
131
  });
132
- const budgetFor = (section) => 높이(ruler) - (chromeOf.get(section ?? '') ?? 0);
133
- const heights = Array.from(el.children).map((c) => 높이(c));
132
+ const budgetFor = (section) => heightOf(ruler) - (chromeOf.get(section ?? '') ?? 0);
133
+ const heights = Array.from(el.children).map((c) => heightOf(c));
134
134
  // 이웃한 같은 group 을 한 덩어리로 묶는다.
135
135
  const groups = [];
136
136
  blocks.forEach((b, i) => {
@@ -179,11 +179,11 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
179
179
  });
180
180
  flush();
181
181
  /* 같은 나눔이면 상태를 그대로 둔다 — 다시 재기(ResizeObserver 첫 울림 등)마다 새 객체로 렌더가 한 번 더 도는 것을 막는다 */
182
- setMeasured((앞) => (앞 && 앞.shape === shape && JSON.stringify(앞.pages) === JSON.stringify(packed) ? 앞 : { shape, pages: packed }));
182
+ setMeasured((prev) => (prev && prev.shape === shape && JSON.stringify(prev.pages) === JSON.stringify(packed) ? prev : { shape, pages: packed }));
183
183
  // blocks 는 위 shape 로 대신 본다.
184
184
  // eslint-disable-next-line react-hooks/exhaustive-deps
185
185
  // 장 규격이 바뀌면 글이 다시 흘러 블록 높이가 달라진다 — 자(세로) 높이는 그대로라 자크기만으로는 다시 재지 않아 장이 넘쳤다
186
- }, [shape, 자크기, pageWidth, pageHeight, margin, 다시재기]);
186
+ }, [shape, rulerSize, pageWidth, pageHeight, margin, remeasureTick]);
187
187
  /* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
188
188
  문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
189
189
  (kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
@@ -208,7 +208,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
208
208
  return (_jsxs("div", { ref: rootRef, className: cx('report-doc', className), style: vars,
209
209
  /* 굴릴 것이 있을 때만 포커스를 받는다. 이름 없이 포커스만 주면 낭독기가
210
210
  문서 전체를 읽어 버리므로 역할과 이름을 함께 준다. */
211
- tabIndex: 넘침 ? 0 : undefined, role: 넘침 ? 'group' : undefined, "aria-label": 넘침 ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsx("div", { ref: chromeRef, children: chromeSections.map((sec) => {
211
+ tabIndex: overflowing ? 0 : undefined, role: overflowing ? 'group' : undefined, "aria-label": overflowing ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsx("div", { ref: chromeRef, children: chromeSections.map((sec) => {
212
212
  const c = chrome(1, 1, sec || undefined);
213
213
  return _jsxs("div", { "data-chrome-section": sec, children: [c.header, c.footer] }, sec);
214
214
  }) }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
@@ -13,7 +13,7 @@ const RING_SIZE_PRESET = {
13
13
  /** 원시 링 렌더링 — 도넛 링 하나만 그린다(헤더/legend 없음) */
14
14
  /** 비율 → 퍼센트 정수. **다 채우기 전에는 100 을 내지 않는다** — 반올림하면 199/200 이 '100%' 인데 링 끝은 비고 범례는
15
15
  * '미완료 1건' 이었다(말귀 숙제 완료율). 같은 까닭으로 0 초과면 0 을 내지 않는다. */
16
- function 퍼센트(ratio) {
16
+ function toPercent(ratio) {
17
17
  if (!Number.isFinite(ratio) || ratio <= 0)
18
18
  return 0;
19
19
  if (ratio >= 1)
@@ -43,7 +43,7 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
43
43
  const pct = (total ?? 0) > 0 ? Math.min((done ?? 0) / total, 1) : 0;
44
44
  const filled = circumference * pct;
45
45
  const gap = circumference - filled;
46
- return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children: 퍼센트(pct) }), !hideLabel && (_jsx("span", { className: `ring-chart__label ${dim <= 96 ? 'caption-2' : 'caption-1'}${dim <= 96 || size === 'medium' ? ' ring-chart__label--tight' : ''}`, children: label }))] }))] }));
46
+ return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children: toPercent(pct) }), !hideLabel && (_jsx("span", { className: `ring-chart__label ${dim <= 96 ? 'caption-2' : 'caption-1'}${dim <= 96 || size === 'medium' ? ' ring-chart__label--tight' : ''}`, children: label }))] }))] }));
47
47
  }
48
48
  /**
49
49
  * 도넛 링 차트 — 전체 대비 완료 비율을 원형 호로 표현.
@@ -55,11 +55,11 @@ export default function RingChart({ titleKey, titleLevel, headRight, icon, showT
55
55
  const { t } = useTranslation();
56
56
  if (!titleKey)
57
57
  return _jsx(RingChartCanvas, { ...ringProps });
58
- const pct = (ringProps.total ?? 0) > 0 ? 퍼센트(Math.min((ringProps.done ?? 0) / ringProps.total, 1)) : 0;
58
+ const pct = (ringProps.total ?? 0) > 0 ? toPercent(Math.min((ringProps.done ?? 0) / ringProps.total, 1)) : 0;
59
59
  // size='small' — 대시보드 작은 그리드용 블럭. 컨트롤(headRight)만 생략하고 total/legend는 medium과 동일하게 유지
60
60
  const isSmall = ringProps.size === 'small';
61
61
  /* 기다림 뼈대의 지름 — 그려질 링과 **같은 셈** 을 쓴다(RingChartCanvas 의 dim) */
62
- const 기다림지름 = typeof ringProps.size === 'number' ? ringProps.size : RING_SIZE_PRESET[ringProps.size ?? 'medium'].diameter;
62
+ const loaderSize = typeof ringProps.size === 'number' ? ringProps.size : RING_SIZE_PRESET[ringProps.size ?? 'medium'].diameter;
63
63
  // size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
64
64
  const isLarge = ringProps.size === 'large';
65
65
  const sectionClassName = [className, isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : '']
@@ -70,5 +70,5 @@ export default function RingChart({ titleKey, titleLevel, headRight, icon, showT
70
70
  /* 링도 같은 병이 있었다 — 기다림 상자 186px, 그려진 링은 지름(medium 120px)이다.
71
71
  **그려질 링과 같은 자리·같은 지름**의 뼈대를 세우고 그 안에서 기다린다
72
72
  (BarChart 와 같은 결, 사용자 지시 2026-09-17 "뼈대 먼저, 그 안에서 로딩"). */
73
- _jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx("div", { className: "ring-chart ring-chart--loading", style: { width: 기다림지름, height: 기다림지름 }, "aria-busy": "true", children: _jsx(Loader, { size: 32 }) }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: ringProps.hideCenter ?? isSmall }) })) }));
73
+ _jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx("div", { className: "ring-chart ring-chart--loading", style: { width: loaderSize, height: loaderSize }, "aria-busy": "true", children: _jsx(Loader, { size: 32 }) }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: ringProps.hideCenter ?? isSmall }) })) }));
74
74
  }