@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
@@ -8,15 +8,20 @@ function decompose(char) {
8
8
  if (code < 0 || code > 11171)
9
9
  return null;
10
10
  return {
11
- 초성: CHO[Math.floor(code / 588)],
12
- 모음: JUNG[Math.floor((code % 588) / 28)],
13
- 종성: JONG[code % 28] || '',
11
+ onset: CHO[Math.floor(code / 588)],
12
+ vowel: JUNG[Math.floor((code % 588) / 28)],
13
+ coda: JONG[code % 28] || '',
14
14
  };
15
15
  }
16
- const SECTION_KEYS = ['초성', '모음', '종성', '활음'];
17
- function isSectionKey(value) {
18
- return SECTION_KEYS.includes(value);
19
- }
16
+ /* 음절 자리 코드는 **아래 모듈**에 있다 — `phonemeConvert` 도 함께 쓰는데, 여기 두면
17
+ `phonemePairSearch ↔ phonemeConvert` 돌림 import 가 된다(한 번 그렇게 해 시험 338 꾸러미가
18
+ 무너졌다, 2026-10-13). */
19
+ import { toPhonemeCodes, toLegacyPhonemeKey, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
20
+ export { toPhonemeCodes, PHONEME_NONE } from './phonemeSectionCodes';
21
+ const SECTION_KEYS = PHONEME_SECTION_CODES;
22
+ const NONE = PHONEME_NONE;
23
+ const isSectionKey = isPhonemeSectionCode;
24
+ const toCodes = toPhonemeCodes;
20
25
  // 발음형 분해: 모음을 활음 + 핵모음으로 분리
21
26
  const VOWEL_GLIDE = {
22
27
  ㅑ: 'j', ㅒ: 'j', ㅕ: 'j', ㅖ: 'j', ㅛ: 'j', ㅠ: 'j',
@@ -38,10 +43,10 @@ function decomposePhonemicSyllable(char) {
38
43
  const rawJung = JUNG[jungIdx];
39
44
  const rawJong = JONG[jongIdx];
40
45
  return {
41
- 초성: rawCho === 'ㅇ' ? '없음' : rawCho,
42
- 활음: VOWEL_GLIDE[rawJung] ?? '없음',
43
- 모음: VOWEL_NUCLEUS[rawJung] ?? rawJung,
44
- 종성: rawJong || '없음',
46
+ onset: rawCho === 'ㅇ' ? NONE : rawCho,
47
+ glide: VOWEL_GLIDE[rawJung] ?? NONE,
48
+ vowel: VOWEL_NUCLEUS[rawJung] ?? rawJung,
49
+ coda: rawJong || NONE,
45
50
  };
46
51
  }
47
52
  function analyzeMinimalPair(word1, word2, phonemic = false) {
@@ -94,8 +99,8 @@ function diffHasEmptyPair(section, vals) {
94
99
  // '활음' 섹션의 '없음'은 비움으로 보지 않는다 — 발음 모드에서 사용자가 활음 자리를 **직접 골라** 찾는 대립(ㅐ↔ㅙ = 없음↔w)이라
95
100
  // 그 검색 결과를 이 필터가 통째로 지우면 안 된다.
96
101
  const isEmpty = (v) => v === '' ||
97
- (section === '종성' && v === '없음') ||
98
- (section === '초성' && (v === 'ㅇ' || v === '없음'));
102
+ (section === 'coda' && v === NONE) ||
103
+ (section === 'onset' && (v === 'ㅇ' || v === NONE));
99
104
  if (isEmpty(vals[0]) || isEmpty(vals[1]))
100
105
  return true;
101
106
  // 모음(글자 모드) 활음 구조 차이: 두 모음의 반모음(활음)이 다르면 음절 '구조' 차이로 본다.
@@ -104,7 +109,7 @@ function diffHasEmptyPair(section, vals) {
104
109
  // 발음 모드는 모음=핵모음이라 둘 다 활음 없음으로 읽혀 영향 없음. 활음 대립은
105
110
  // 별도 '활음' 섹션에서 처리되므로 여기(모음)에서 걸러도 활음 지정 대립과 충돌하지 않는다.
106
111
  // 단어훈련 런타임(wordContrast.hasStructureContrast)도 **같은 함수**로 판정한다.
107
- if (section === '모음' && vowelGlidesDiffer(vals[0], vals[1]))
112
+ if (section === 'vowel' && vowelGlidesDiffer(vals[0], vals[1]))
108
113
  return true;
109
114
  return false;
110
115
  }
@@ -125,13 +130,13 @@ const DEFAULT_PAIR_FILTER_KEYS = {
125
130
  posMap: POS_GROUP_MAP_KO,
126
131
  typeMap: WORD_TYPE_MAP_KO,
127
132
  };
128
- const 모은것 = new WeakMap();
129
- function 모으기(filterSelected, keys) {
130
- const 있음 = 모은것.get(filterSelected);
131
- if (있음 && 있음.keys === keys)
132
- return 있음.값;
133
- const 값 = { grades: [], syls: [], poses: [], types: [] };
134
- const 칸 = { grade: 값.grades, syllables: 값.syls, pos: 값.poses, wordType: 값.types };
133
+ const collected = new WeakMap();
134
+ function collect(filterSelected, keys) {
135
+ const hit = collected.get(filterSelected);
136
+ if (hit && hit.keys === keys)
137
+ return hit.value;
138
+ const value = { grades: [], syls: [], poses: [], types: [] };
139
+ const bySection = { grade: value.grades, syllables: value.syls, pos: value.poses, wordType: value.types };
135
140
  for (const [label, raw] of Object.entries(filterSelected ?? {})) {
136
141
  /* 섹션은 넘긴 keys 의 라벨이면 그것, 아니면 어느 언어·옛 라벨·코드든 읽는다(wordAttributeSectionOf) */
137
142
  const s = label === keys.difficulty ? 'grade' : label === keys.syllables ? 'syllables'
@@ -140,11 +145,11 @@ function 모으기(filterSelected, keys) {
140
145
  if (!s)
141
146
  continue;
142
147
  for (const v of Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [])
143
- if (!칸[s].includes(v))
144
- 칸[s].push(v);
148
+ if (!bySection[s].includes(v))
149
+ bySection[s].push(v);
145
150
  }
146
- 모은것.set(filterSelected, { keys, 값 });
147
- return 값;
151
+ collected.set(filterSelected, { keys, value });
152
+ return value;
148
153
  }
149
154
  /**
150
155
  * 난이도/음절수/품사/유형 필터를 단어 하나에 대해 판정(의미범주 제외 — WordEntry 전용이라 applyFilters에서 별도 처리).
@@ -154,7 +159,7 @@ function 모으기(filterSelected, keys) {
154
159
  * **풀리고** 품사·어종 값은 posMap 에 없어 **모든 단어가 떨어졌다**.
155
160
  */
156
161
  export function matchesFilters(word, filterSelected, keys = DEFAULT_PAIR_FILTER_KEYS) {
157
- const { grades, syls, poses, types } = 모으기(filterSelected, keys);
162
+ const { grades, syls, poses, types } = collect(filterSelected, keys);
158
163
  if (grades.length) {
159
164
  /* **`없음` 은 값이 아니라 빔이다**((205), 2026-10-12) — 거르는 결정은 `matchesGradeCodes` **하나**다
160
165
  (단어훈련 풀도 같은 것을 부른다). 여기서는 라벨을 코드로만 바꾼다.
@@ -278,9 +283,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
278
283
  const uniqueWordsCount = new Set(words.map(w => w.word)).size;
279
284
  const bySection = {};
280
285
  const bySectionValueOrder = {};
281
- for (let i = 0; i < selectedList.length; i++) {
282
- for (const [section, value] of Object.entries(selectedList[i])) {
283
- if (!isSectionKey(section))
286
+ /* **들어오는 자리에서 한 번 옮긴다** — 저장된 단어표의 `phonemeSelected` 는 옛 한글 열쇠일
287
+ 수 있다(`{ 초성: 'ㄴ' }`). 안쪽은 코드만 보므로 여기서 가른다. */
288
+ const codedList = selectedList.map(toCodes);
289
+ for (let i = 0; i < codedList.length; i++) {
290
+ for (const [section, value] of Object.entries(codedList[i])) {
291
+ /* **`!value` 로 거르지 않는다.** `{ 종성: '' }` 은 "받침 없음을 골랐다" 는 뜻이고,
292
+ 옮기며 그 줄을 더했다가 `받침 있음↔없음` 검색이 빈손이 됐다(2026-10-13, 시험이 잡았다).
293
+ *옮기기에 거르는 줄을 끼워 넣으면 뜻이 바뀐다.* */
294
+ if (!isSectionKey(section) || value === undefined)
284
295
  continue;
285
296
  if (!bySection[section])
286
297
  bySection[section] = new Set();
@@ -315,16 +326,16 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
315
326
  const targetKeys = new Set(sectionEntries.map(([s]) => s));
316
327
  const contrastSections = new Set(sectionEntries.filter(([, vals]) => vals.size >= 2).map(([s]) => s));
317
328
  const encodeAll = phonemic
318
- ? (s) => s.초성 + (s.활음 ?? '') + s.모음 + s.종성
319
- : (s) => s.초성 + s.모음 + s.종성;
329
+ ? (s) => s.onset + (s.glide ?? '') + s.vowel + s.coda
330
+ : (s) => s.onset + s.vowel + s.coda;
320
331
  const encodeWild = phonemic
321
- ? (s) => (targetKeys.has('초성') ? '*' : s.초성) +
322
- (targetKeys.has('활음') ? '*' : (s.활음 ?? '')) +
323
- (targetKeys.has('모음') ? '*' : s.모음) +
324
- (targetKeys.has('종성') ? '*' : s.종성)
325
- : (s) => (targetKeys.has('초성') ? '*' : s.초성) +
326
- (targetKeys.has('모음') ? '*' : s.모음) +
327
- (targetKeys.has('종성') ? '*' : s.종성);
332
+ ? (s) => (targetKeys.has('onset') ? '*' : s.onset) +
333
+ (targetKeys.has('glide') ? '*' : (s.glide ?? '')) +
334
+ (targetKeys.has('vowel') ? '*' : s.vowel) +
335
+ (targetKeys.has('coda') ? '*' : s.coda)
336
+ : (s) => (targetKeys.has('onset') ? '*' : s.onset) +
337
+ (targetKeys.has('vowel') ? '*' : s.vowel) +
338
+ (targetKeys.has('coda') ? '*' : s.coda);
328
339
  const makeKeyForPosition = (syllables, diffPos) => syllables.map((s, i) => (i === diffPos ? encodeWild(s) : encodeAll(s))).join('|');
329
340
  const gradeOrder = (g) => {
330
341
  const parsed = Number.parseInt(g ?? '', 10);
@@ -335,9 +346,9 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
335
346
  한 묶음의 낱말은 모두 같은 자리를 비운 것이다. 분해한 음절도 함께 든다(견줄 때마다 다시 분해하지 않게). */
336
347
  const groups = new Map();
337
348
  /* 표기가 같아도 뜻마다 칸이 하나다 — 판정은 대표로 한 번만 하고 결과에서 뜻을 펼친다 */
338
- const { 대표들, 뜻들 } = 뜻묶기(words);
349
+ const { heads, senses } = groupBySense(words);
339
350
  const seenWords = new Set();
340
- for (const entry of 대표들) {
351
+ for (const entry of heads) {
341
352
  seenWords.add(entry.word);
342
353
  const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
343
354
  const syllables = [...cleanPron(entry.pronunciation)].map(decomposeFn);
@@ -346,25 +357,25 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
346
357
  const syls = syllables;
347
358
  for (let p = 0; p < syls.length; p++) {
348
359
  const key = makeKeyForPosition(syls, p);
349
- let 묶음 = groups.get(key);
350
- if (!묶음) {
351
- 묶음 = { 비운자리: p, 낱말들: [] };
352
- groups.set(key, 묶음);
360
+ let group = groups.get(key);
361
+ if (!group) {
362
+ group = { emptyAt: p, members: [] };
363
+ groups.set(key, group);
353
364
  }
354
- 묶음.낱말들.push({ entry, syls });
365
+ group.members.push({ entry, syls });
355
366
  }
356
367
  }
357
368
  const results = [];
358
369
  const rejectCounts = createRejectCounter();
359
370
  const rejectSamples = [];
360
371
  let comparedPairs = 0;
361
- for (const { 비운자리, 낱말들 } of groups.values()) {
362
- if (낱말들.length < 2)
372
+ for (const { emptyAt, members } of groups.values()) {
373
+ if (members.length < 2)
363
374
  continue;
364
- for (let i = 0; i < 낱말들.length; i++) {
365
- for (let j = i + 1; j < 낱말들.length; j++) {
366
- const w1 = 낱말들[i].entry;
367
- const w2 = 낱말들[j].entry;
375
+ for (let i = 0; i < members.length; i++) {
376
+ for (let j = i + 1; j < members.length; j++) {
377
+ const w1 = members[i].entry;
378
+ const w2 = members[j].entry;
368
379
  /* 같은 표기가 발음만 다르게 두 번 들어온 자료(김밥[김밥]·김밥[김빱])는 짝으로 세지 않는다 —
369
380
  칸 둘의 이름이 같은 '김밥/김밥' 쌍이 된다. 견준 쌍을 다 기억하던 전에는 이것이
370
381
  덤으로 막히고 있었다(같은 표기면 열쇠가 하나라 한 번만 지나갔다). */
@@ -383,15 +394,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
383
394
  남은 것은 '한 군데만 다른가' 를 쌍 문자열·발음 분해 전에 여기서 재는 일이다 —
384
395
  analyzeMinimalPair 와 같은 잣대(SECTION_KEYS)를 그 한 자리에만 쓴다. 포함 검색
385
396
  (searchWordsByPhonemes)이 전부터 하던 것과 같은 걸름이다. */
386
- const s1 = 낱말들[i].syls[비운자리];
387
- const s2 = 낱말들[j].syls[비운자리];
388
- let 다른칸수 = 0;
397
+ const s1 = members[i].syls[emptyAt];
398
+ const s2 = members[j].syls[emptyAt];
399
+ let diffCount = 0;
389
400
  for (const sec of SECTION_KEYS)
390
401
  if (s1[sec] !== s2[sec])
391
- 다른칸수 += 1;
402
+ diffCount += 1;
392
403
  comparedPairs += 1;
393
- if (다른칸수 !== 1) {
394
- const reason = 다른칸수 === 0 ? 'no_difference' : 'multiple_differences';
404
+ if (diffCount !== 1) {
405
+ const reason = diffCount === 0 ? 'no_difference' : 'multiple_differences';
395
406
  rejectCounts[reason] += 1;
396
407
  if (debug && rejectSamples.length < 20)
397
408
  rejectSamples.push({ id: `${w1.word}|${w2.word}`, reason });
@@ -421,13 +432,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
421
432
  const sDiffVals = analysis.diff.diffs[sDiffSection];
422
433
  const sDiffHasEmpty = diffHasEmptyPair(sDiffSection, sDiffVals);
423
434
  // 뜻마다 한 칸 — '눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍을 이룬다
424
- for (const f of 뜻들.get(first) ?? [first]) {
425
- for (const sec of 뜻들.get(second) ?? [second]) {
435
+ for (const f of senses.get(first) ?? [first]) {
436
+ for (const sec of senses.get(second) ?? [second]) {
426
437
  results.push({
427
- id: `${낱말열쇠(f)}|${낱말열쇠(sec)}`,
428
- diffSection: sDiffSection,
438
+ id: `${wordKey(f)}|${wordKey(sec)}`,
439
+ /* 옛 이름을 내보내고 코드를 나란히 — 소비자가 옛 값으로 묶음을 가른다 */
440
+ diffSection: toLegacyPhonemeKey(sDiffSection),
441
+ diffSectionCode: sDiffSection,
429
442
  diffHasEmpty: sDiffHasEmpty,
430
- words: [낱말칸(f), 낱말칸(sec)],
443
+ words: [wordCells(f), wordCells(sec)],
431
444
  });
432
445
  }
433
446
  }
@@ -469,38 +482,38 @@ export function searchPairs(selectedList, words, enforcePosition, phonemic = fal
469
482
  // 슬롯 i → 단어의 i번째 음절에 조건 매칭 (순서 보장)
470
483
  /** 표기가 같아도 뜻이 다르면 다른 칸 — 칸에 붙는 그림이 뜻마다 다르다(사용자 결정 2026-09-16).
471
484
  * 동음이의 번호가 없는 자료는 예전처럼 표기뿐이다. */
472
- function 낱말열쇠(entry) {
485
+ function wordKey(entry) {
473
486
  return entry.homonum != null ? `${entry.word}#${entry.homonum}` : entry.word;
474
487
  }
475
488
  /** 표기·발음이 같은 뜻들을 한 대표로 묶는다 — 최소대립쌍 판정은 표기와 발음만 보므로 뜻마다 다시 셀 까닭이 없다.
476
489
  * (뜻을 모두 후보로 넣으면 묶음이 커져 23,000 낱말에서 검색이 2–4배 느려졌다.) 결과를 만들 때 뜻마다 칸을 펼친다. */
477
490
  /** 결과 칸 하나 */
478
- function 낱말칸(e) {
491
+ function wordCells(e) {
479
492
  return {
480
493
  label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
481
494
  grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType, isCustom: e.isCustom,
482
495
  };
483
496
  }
484
- function 뜻묶기(words) {
485
- const 자리 = new Map();
486
- const 뜻들 = new Map();
487
- const 대표들 = [];
497
+ function groupBySense(words) {
498
+ const byKey = new Map();
499
+ const senses = new Map();
500
+ const heads = [];
488
501
  for (const entry of words) {
489
- const 열쇠 = `${entry.word}\u0000${cleanPron(entry.pronunciation)}`;
490
- const 대표 = 자리.get(열쇠);
491
- if (대표 === undefined) {
492
- 자리.set(열쇠, entry);
493
- 대표들.push(entry);
494
- 뜻들.set(entry, [entry]);
502
+ const key = `${entry.word}\u0000${cleanPron(entry.pronunciation)}`;
503
+ const head = byKey.get(key);
504
+ if (head === undefined) {
505
+ byKey.set(key, entry);
506
+ heads.push(entry);
507
+ senses.set(entry, [entry]);
495
508
  continue;
496
509
  }
497
- const 목록 = 뜻들.get(대표);
510
+ const list = senses.get(head);
498
511
  // 같은 번호가 두 번 오면(자료 중복) 한 번만
499
- if (목록.some(e => 낱말열쇠(e) === 낱말열쇠(entry)))
512
+ if (list.some(e => wordKey(e) === wordKey(entry)))
500
513
  continue;
501
- 목록.push(entry);
514
+ list.push(entry);
502
515
  }
503
- return { 대표들, 뜻들 };
516
+ return { heads, senses };
504
517
  }
505
518
  function wordContainsAllPhonemes(syllables, requirements) {
506
519
  for (let i = 0; i < requirements.length; i++) {
@@ -534,18 +547,20 @@ export function searchWordsByPhonemes(requiredPhonemes, words, ordered = true, p
534
547
  * 이 조건을 만족하는 쪽을 우선한다(예: "검색 단어만" 필터 통과 여부). 지정하지
535
548
  * 않으면 기존 동작(먼저 발견된 방향을 그대로 채택)과 동일하다. */
536
549
  preferAsTarget) {
537
- const reqs = requiredPhonemes.filter(r => Object.keys(r).some(k => isSectionKey(k)));
550
+ /* 들어오는 자리에서 코드로 — 옛 한글 열쇠로 저장된 표도 그대로 읽힌다 */
551
+ const coded = requiredPhonemes.map(toCodes);
552
+ const reqs = coded.filter(r => Object.keys(r).some(k => isSectionKey(k)));
538
553
  if (reqs.length === 0)
539
554
  return [];
540
555
  const encodeAll = phonemic
541
- ? (s) => s.초성 + (s.활음 ?? '') + s.모음 + s.종성
542
- : (s) => s.초성 + s.모음 + s.종성;
556
+ ? (s) => s.onset + (s.glide ?? '') + s.vowel + s.coda
557
+ : (s) => s.onset + s.vowel + s.coda;
543
558
  const makeKey = (syls, pos) => syls.map((s, i) => (i === pos ? '*' : encodeAll(s))).join('|');
544
559
  const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
545
560
  const allWords = [];
546
- const { 대표들, 뜻들 } = 뜻묶기(words);
561
+ const { heads, senses } = groupBySense(words);
547
562
  const seenWords = new Set();
548
- for (const entry of 대표들) {
563
+ for (const entry of heads) {
549
564
  seenWords.add(entry.word);
550
565
  const syls = [...cleanPron(entry.pronunciation)].map(decomposeFn);
551
566
  if (syls.some(s => s === null))
@@ -563,7 +578,7 @@ preferAsTarget) {
563
578
  /* 일치(ordered) 는 조건의 자리가 곧 음절 자리다 — 빈 슬롯을 걸러 낸 reqs 를 쓰면 [{}, {초성:ㄴ}] 의 ㄴ 이 첫 음절에 걸렸다.
564
579
  wordContainsAllPhonemes 는 빈 슬롯을 스스로 건너뛴다. */
565
580
  return ordered
566
- ? wordContainsAllPhonemes(filterSyls, requiredPhonemes)
581
+ ? wordContainsAllPhonemes(filterSyls, coded)
567
582
  : wordContainsAllPhonemesUnordered(filterSyls, reqs);
568
583
  });
569
584
  const groups = new Map();
@@ -575,7 +590,7 @@ preferAsTarget) {
575
590
  groups.get(key).push(w);
576
591
  }
577
592
  }
578
- const 쌍들 = [];
593
+ const pairs = [];
579
594
  // dedupKey → 쌍들 배열 내 인덱스. 같은 두 단어가 서로 minimal pair이면서
580
595
  // 둘 다 독립적으로 targetWords에 속할 수 있어(예: 다른 음절에 요구 음소가 하나 더 있는 경우),
581
596
  // 어느 쪽을 target(1번/words[0])으로 채택할지가 순회 순서에 좌우될 수 있다.
@@ -594,13 +609,13 @@ preferAsTarget) {
594
609
  {
595
610
  const cs = candidate.syls[p];
596
611
  let d = 0;
597
- if (ts.초성 !== cs.초성)
612
+ if (ts.onset !== cs.onset)
598
613
  d++;
599
- if (ts.모음 !== cs.모음)
614
+ if (ts.vowel !== cs.vowel)
600
615
  d++;
601
- if (ts.종성 !== cs.종성)
616
+ if (ts.coda !== cs.coda)
602
617
  d++;
603
- if ((ts.활음 ?? '') !== (cs.활음 ?? ''))
618
+ if ((ts.glide ?? '') !== (cs.glide ?? ''))
604
619
  d++;
605
620
  if (d !== 1)
606
621
  continue;
@@ -612,7 +627,7 @@ preferAsTarget) {
612
627
  if (existingIdx !== undefined) {
613
628
  if (!preferAsTarget)
614
629
  continue;
615
- if (preferAsTarget(낱말칸(쌍들[existingIdx].target)))
630
+ if (preferAsTarget(wordCells(pairs[existingIdx].target)))
616
631
  continue;
617
632
  if (!preferAsTarget({ grade: target.entry.grade, syllables: [...target.entry.word].length, pos: target.entry.pos, wordType: target.entry.wordType }))
618
633
  continue;
@@ -635,12 +650,12 @@ preferAsTarget) {
635
650
  if (tv !== cv)
636
651
  diffValues = [tv, cv];
637
652
  }
638
- const 맞나 = (req) => {
653
+ const matches = (req) => {
639
654
  const reqVal = req?.[diffSection];
640
655
  return reqVal !== undefined && (diffValues[0] === reqVal || diffValues[1] === reqVal);
641
656
  };
642
657
  // 일치(ordered): "첫번째 음절에서 초성 ㄱ 이 대립" — 대립한 자리의 조건이어야 한다(다른 음절에서 대립한 쌍이 섞였다)
643
- const isRelevant = ordered ? 맞나(requiredPhonemes[analysis.diff.idx]) : reqs.some(맞나);
658
+ const isRelevant = ordered ? matches(coded[analysis.diff.idx]) : reqs.some(matches);
644
659
  if (!isRelevant)
645
660
  continue;
646
661
  }
@@ -649,26 +664,27 @@ preferAsTarget) {
649
664
  const diffHasEmpty = diffHasEmptyPair(diffSection, diffVals);
650
665
  const item = { target: target.entry, candidate: candidate.entry, diffSection, diffHasEmpty };
651
666
  if (existingIdx !== undefined) {
652
- 쌍들[existingIdx] = item;
667
+ pairs[existingIdx] = item;
653
668
  }
654
669
  else {
655
- seenPairs.set(dedupKey, 쌍들.length);
656
- 쌍들.push(item);
670
+ seenPairs.set(dedupKey, pairs.length);
671
+ pairs.push(item);
657
672
  }
658
673
  }
659
674
  }
660
675
  }
661
676
  /* 뜻마다 한 칸 — 기준·짝의 뜻을 모두 펼친다('눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍). id 는 정렬 꼴에 번호까지. */
662
677
  const results = [];
663
- for (const 것 of 쌍들) {
664
- for (const t of 뜻들.get(것.target) ?? [것.target]) {
665
- for (const c of 뜻들.get(것.candidate) ?? [것.candidate]) {
666
- const kt = 낱말열쇠(t), kc = 낱말열쇠(c);
678
+ for (const item of pairs) {
679
+ for (const t of senses.get(item.target) ?? [item.target]) {
680
+ for (const c of senses.get(item.candidate) ?? [item.candidate]) {
681
+ const kt = wordKey(t), kc = wordKey(c);
667
682
  results.push({
668
683
  id: kt < kc ? `${kt}|${kc}` : `${kc}|${kt}`,
669
- diffSection: 것.diffSection,
670
- diffHasEmpty: 것.diffHasEmpty,
671
- words: [낱말칸(t), 낱말칸(c)],
684
+ diffSection: toLegacyPhonemeKey(item.diffSection),
685
+ diffSectionCode: item.diffSection,
686
+ diffHasEmpty: item.diffHasEmpty,
687
+ words: [wordCells(t), wordCells(c)],
672
688
  });
673
689
  }
674
690
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * **음절 자리의 코드** — `onset`·`vowel`·`coda`·`glide`, 그리고 빔(`none`).
3
+ *
4
+ * ## 왜 제 모듈인가
5
+ *
6
+ * 처음에는 이 가름을 `phonemePairSearch` 에 두고 `phonemeConvert` 가 들여왔다 — 그런데
7
+ * `phonemePairSearch` 도 `phonemeConvert`(`VOWEL_TO_PHONEME`)를 들여온다. **돌림 import** 가
8
+ * 되어 시험 **338 꾸러미**가 한꺼번에 무너졌다(2026-10-13). 두 쪽이 함께 쓰는 것은 **어느
9
+ * 한쪽이 아니라 그 아래**에 둔다.
10
+ *
11
+ * ## 왜 코드인가
12
+ *
13
+ * 전에는 열쇠가 한글(`'초성'`)이었고 그것이 **저장된다** — 단어표의
14
+ * `searchConfig.phonemeSelected` 가 그 꼴이다. 저장되는 값이 표시 언어에 묶이면 말을 고치려
15
+ * 해도 자료를 밀어야 한다(사장님 지시 2026-10-13 "동작에 한글이 끼지 말라").
16
+ *
17
+ * **값을 바꾸지 않고 읽는 쪽을 너그럽게 했다** — 표준의 *lenient reader · strict writer*.
18
+ * 저장된 표를 밀지 않아도 되고 소비자 코드도 안 바뀐다. 안쪽 로직에서만 한글이 사라진다.
19
+ *
20
+ * 어휘는 **훈련 필터가 이미 쓰던 것**과 같게 뒀다(`useWordTrainingFilter` 의 `onset`·`vowel`·
21
+ * `coda`) — 같은 뜻에 어휘가 둘이던 것을 모았다.
22
+ *
23
+ * **값(자모 `'ㄴ'`)은 그대로다** — 그것은 한국어 글자 그 자체이고 바꿀 것은 열쇠와 빔 값이다.
24
+ */
25
+ /** 음절 자리 코드 @public */
26
+ export type PhonemeSectionCode = 'onset' | 'vowel' | 'coda' | 'glide';
27
+ /** @public */
28
+ export declare const PHONEME_SECTION_CODES: PhonemeSectionCode[];
29
+ /** 자리가 비었음 — 초성 ㅇ · 받침 없음 · 활음 없음을 한 값으로 적는다 @public */
30
+ export declare const PHONEME_NONE = "none";
31
+ /** @public */
32
+ export declare function isPhonemeSectionCode(value: string): value is PhonemeSectionCode;
33
+ /**
34
+ * **들어오는 고른값 하나를 코드 꼴로.** 옛 한글 열쇠·옛 빔 값도 받는다.
35
+ *
36
+ * 공개 함수(`searchPairs`·`searchWordsByPhonemes`·`convertToPhoneme`)가 받는
37
+ * `Record<string, string>` 은 **저장된 검색 구성에서 온다** — 그 꼴을 바꾸면 이미 저장된
38
+ * 단어표가 깨진다. 그래서 들어오는 자리에서 한 번 옮기고, 안쪽은 코드만 본다.
39
+ *
40
+ * @public
41
+ */
42
+ export declare function toPhonemeCodes(sel: Record<string, string>): Partial<Record<PhonemeSectionCode, string>>;
43
+ /**
44
+ * 코드를 **옛 열쇠 글자로** — 밖으로 나가는 `diffSection` 이 그 꼴이다.
45
+ *
46
+ * 소비자가 그 값으로 묶음을 가른다(말귀 `WordPairsPage` 의 `GROUP_ORDER`·`GROUP_LABEL`).
47
+ * 혼자 코드로 바꾸면 **그쪽 묶음이 조용히 빈다** — 그래서 옛 이름을 내보내고, 코드는
48
+ * `diffSectionCode` 로 **나란히** 둔다(2026-10-13). 소비자가 옮긴 뒤 옛 쪽을 걷는다.
49
+ *
50
+ * @public
51
+ */
52
+ export declare function toLegacyPhonemeKey(code: PhonemeSectionCode): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * **음절 자리의 코드** — `onset`·`vowel`·`coda`·`glide`, 그리고 빔(`none`).
3
+ *
4
+ * ## 왜 제 모듈인가
5
+ *
6
+ * 처음에는 이 가름을 `phonemePairSearch` 에 두고 `phonemeConvert` 가 들여왔다 — 그런데
7
+ * `phonemePairSearch` 도 `phonemeConvert`(`VOWEL_TO_PHONEME`)를 들여온다. **돌림 import** 가
8
+ * 되어 시험 **338 꾸러미**가 한꺼번에 무너졌다(2026-10-13). 두 쪽이 함께 쓰는 것은 **어느
9
+ * 한쪽이 아니라 그 아래**에 둔다.
10
+ *
11
+ * ## 왜 코드인가
12
+ *
13
+ * 전에는 열쇠가 한글(`'초성'`)이었고 그것이 **저장된다** — 단어표의
14
+ * `searchConfig.phonemeSelected` 가 그 꼴이다. 저장되는 값이 표시 언어에 묶이면 말을 고치려
15
+ * 해도 자료를 밀어야 한다(사장님 지시 2026-10-13 "동작에 한글이 끼지 말라").
16
+ *
17
+ * **값을 바꾸지 않고 읽는 쪽을 너그럽게 했다** — 표준의 *lenient reader · strict writer*.
18
+ * 저장된 표를 밀지 않아도 되고 소비자 코드도 안 바뀐다. 안쪽 로직에서만 한글이 사라진다.
19
+ *
20
+ * 어휘는 **훈련 필터가 이미 쓰던 것**과 같게 뒀다(`useWordTrainingFilter` 의 `onset`·`vowel`·
21
+ * `coda`) — 같은 뜻에 어휘가 둘이던 것을 모았다.
22
+ *
23
+ * **값(자모 `'ㄴ'`)은 그대로다** — 그것은 한국어 글자 그 자체이고 바꿀 것은 열쇠와 빔 값이다.
24
+ */
25
+ /** @public */
26
+ export const PHONEME_SECTION_CODES = ['onset', 'vowel', 'coda', 'glide'];
27
+ /** 자리가 비었음 — 초성 ㅇ · 받침 없음 · 활음 없음을 한 값으로 적는다 @public */
28
+ export const PHONEME_NONE = 'none';
29
+ /** 저장된 옛 열쇠 → 코드. 옛 표를 읽을 때만 쓰는 다리다(새로 쓰는 것은 코드뿐) */
30
+ const LEGACY_SECTION_KEY = {
31
+ 초성: 'onset', 모음: 'vowel', 종성: 'coda', 활음: 'glide',
32
+ };
33
+ /** 저장된 옛 '빔' 값 */
34
+ const LEGACY_NONE = '없음';
35
+ /** @public */
36
+ export function isPhonemeSectionCode(value) {
37
+ return PHONEME_SECTION_CODES.includes(value);
38
+ }
39
+ /**
40
+ * **들어오는 고른값 하나를 코드 꼴로.** 옛 한글 열쇠·옛 빔 값도 받는다.
41
+ *
42
+ * 공개 함수(`searchPairs`·`searchWordsByPhonemes`·`convertToPhoneme`)가 받는
43
+ * `Record<string, string>` 은 **저장된 검색 구성에서 온다** — 그 꼴을 바꾸면 이미 저장된
44
+ * 단어표가 깨진다. 그래서 들어오는 자리에서 한 번 옮기고, 안쪽은 코드만 본다.
45
+ *
46
+ * @public
47
+ */
48
+ export function toPhonemeCodes(sel) {
49
+ const out = {};
50
+ for (const [k, v] of Object.entries(sel)) {
51
+ const key = isPhonemeSectionCode(k) ? k : LEGACY_SECTION_KEY[k];
52
+ /* **빈 값은 버리지 않는다.** `{ 종성: '' }` 은 "받침 없음을 골랐다" 는 **뜻 있는 고름**이다 —
53
+ 한 번 버렸다가 `받침 있음↔없음` 대립 검색이 빈손이 됐다(2026-10-13, 시험이 잡았다).
54
+ 거르는 것은 **열쇠를 못 알아볼 때**뿐이다. */
55
+ if (!key)
56
+ continue;
57
+ out[key] = v === LEGACY_NONE ? PHONEME_NONE : v;
58
+ }
59
+ return out;
60
+ }
61
+ /** 코드 → 저장된 옛 열쇠. 밖으로 내보내는 값이 옛 꼴이어야 할 때만 쓴다 */
62
+ const CODE_TO_LEGACY = {
63
+ onset: '초성', vowel: '모음', coda: '종성', glide: '활음',
64
+ };
65
+ /**
66
+ * 코드를 **옛 열쇠 글자로** — 밖으로 나가는 `diffSection` 이 그 꼴이다.
67
+ *
68
+ * 소비자가 그 값으로 묶음을 가른다(말귀 `WordPairsPage` 의 `GROUP_ORDER`·`GROUP_LABEL`).
69
+ * 혼자 코드로 바꾸면 **그쪽 묶음이 조용히 빈다** — 그래서 옛 이름을 내보내고, 코드는
70
+ * `diffSectionCode` 로 **나란히** 둔다(2026-10-13). 소비자가 옮긴 뒤 옛 쪽을 걷는다.
71
+ *
72
+ * @public
73
+ */
74
+ export function toLegacyPhonemeKey(code) {
75
+ return CODE_TO_LEGACY[code];
76
+ }
@@ -26,19 +26,19 @@
26
26
  * @public
27
27
  */
28
28
  export function prefetchChunk(...loads) {
29
- let 취소됨 = false;
30
- const 받기 = () => { if (!취소됨)
29
+ let cancelled = false;
30
+ const load = () => { if (!cancelled)
31
31
  for (const load of loads)
32
32
  void load().catch(() => { }); };
33
33
  const idle = globalThis.requestIdleCallback;
34
34
  if (idle) {
35
- const id = idle(받기, { timeout: 2000 });
35
+ const id = idle(load, { timeout: 2000 });
36
36
  return () => {
37
- 취소됨 = true;
37
+ cancelled = true;
38
38
  const cancel = globalThis.cancelIdleCallback;
39
39
  cancel?.(id);
40
40
  };
41
41
  }
42
- const id = setTimeout(받기, 300);
43
- return () => { 취소됨 = true; clearTimeout(id); };
42
+ const id = setTimeout(load, 300);
43
+ return () => { cancelled = true; clearTimeout(id); };
44
44
  }
@@ -17,7 +17,7 @@
17
17
  export declare function pressable<E extends {
18
18
  key: string;
19
19
  preventDefault: () => void;
20
- }>(onPress?: (e: E) => void, 옵션?: {
20
+ }>(onPress?: (e: E) => void, options?: {
21
21
  role?: 'button' | 'option';
22
22
  selected?: boolean;
23
23
  }): {};
@@ -14,13 +14,13 @@
14
14
  * 진짜 `<button>` 을 쓸 수 있으면 그쪽이 낫다. 이건 이미 `<div>`·`<li>` 로 짜인
15
15
  * 자리를 **모양을 안 바꾸고** 눌리게 만드는 길이다.
16
16
  */
17
- export function pressable(onPress, 옵션) {
17
+ export function pressable(onPress, options) {
18
18
  if (!onPress)
19
19
  return {};
20
20
  return {
21
- role: 옵션?.role ?? 'button',
21
+ role: options?.role ?? 'button',
22
22
  tabIndex: 0,
23
- ...(옵션?.selected === undefined ? {} : { 'aria-selected': 옵션.selected }),
23
+ ...(options?.selected === undefined ? {} : { 'aria-selected': options.selected }),
24
24
  onKeyDown: (e) => {
25
25
  /* **이 자리 자신에서 난 키만** 받는다. 안에 든 진짜 단추(랜딩 띠의 action 등)에서 누른 Enter 가 올라오면
26
26
  전에는 여기서 preventDefault 해 그 단추의 click 을 막고 이 자리의 onPress 를 대신 불렀다. */
@@ -8,16 +8,16 @@
8
8
  export function isSafeOpenHref(href) {
9
9
  if (typeof href !== 'string')
10
10
  return false;
11
- let 걷은 = '';
11
+ let stripped = '';
12
12
  for (const ch of href) {
13
13
  const c = ch.charCodeAt(0);
14
14
  if (c <= 0x20 || (c >= 0x7f && c <= 0x9f))
15
15
  continue;
16
- 걷은 += ch;
16
+ stripped += ch;
17
17
  }
18
- if (!걷은)
18
+ if (!stripped)
19
19
  return false;
20
- const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(걷은);
20
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(stripped);
21
21
  if (!m)
22
22
  return true; // 스킴이 없다 — 상대 경로(`/help`·`./a`·`?q`·`#x`)·`//host` 는 지금 문서의 스킴을 따른다
23
23
  return ['http', 'https', 'mailto', 'tel'].includes(m[1].toLowerCase());