@ihabdevteam/core 0.128.0 → 0.130.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 (115) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +182 -182
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +268 -66
  10. package/dist/packages/core/src/components/AudioMeter.js +29 -15
  11. package/dist/packages/core/src/components/Avatar.js +6 -2
  12. package/dist/packages/core/src/components/Badge.d.ts +5 -1
  13. package/dist/packages/core/src/components/Badge.js +19 -5
  14. package/dist/packages/core/src/components/BulkBar.d.ts +2 -1
  15. package/dist/packages/core/src/components/BulkBar.js +9 -2
  16. package/dist/packages/core/src/components/Calendar.js +1 -1
  17. package/dist/packages/core/src/components/ColorPicker.d.ts +3 -1
  18. package/dist/packages/core/src/components/ColorPicker.js +4 -2
  19. package/dist/packages/core/src/components/DatePicker.d.ts +2 -1
  20. package/dist/packages/core/src/components/DatePicker.js +9 -4
  21. package/dist/packages/core/src/components/Effect/EffectPartyPopper.d.ts +9 -0
  22. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +30 -25
  23. package/dist/packages/core/src/components/EmptyState.js +3 -1
  24. package/dist/packages/core/src/components/ErrorState.js +3 -1
  25. package/dist/packages/core/src/components/FilterPanel.js +7 -0
  26. package/dist/packages/core/src/components/Header.d.ts +6 -0
  27. package/dist/packages/core/src/components/Header.js +25 -1
  28. package/dist/packages/core/src/components/Icon.css +22 -0
  29. package/dist/packages/core/src/components/Icon.d.ts +7 -3
  30. package/dist/packages/core/src/components/Icon.js +7 -1
  31. package/dist/packages/core/src/components/IconGridMenu.css +4 -2
  32. package/dist/packages/core/src/components/IconGridMenu.d.ts +1 -1
  33. package/dist/packages/core/src/components/IconGridMenu.js +6 -3
  34. package/dist/packages/core/src/components/LandingHeader.js +6 -3
  35. package/dist/packages/core/src/components/Loader.d.ts +1 -1
  36. package/dist/packages/core/src/components/Loader.js +7 -2
  37. package/dist/packages/core/src/components/NoticeBox.d.ts +5 -0
  38. package/dist/packages/core/src/components/NoticeBox.js +3 -1
  39. package/dist/packages/core/src/components/Pagination.d.ts +2 -2
  40. package/dist/packages/core/src/components/Pagination.js +2 -2
  41. package/dist/packages/core/src/components/PanelRow.js +5 -1
  42. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +10 -1
  43. package/dist/packages/core/src/components/PaymentPlan.d.ts +8 -1
  44. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +1 -1
  45. package/dist/packages/core/src/components/Popup/ListPopup.d.ts +8 -1
  46. package/dist/packages/core/src/components/Popup/ListPopup.js +17 -2
  47. package/dist/packages/core/src/components/Popup/Popup.css +1 -1
  48. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  49. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +3 -2
  50. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.d.ts +4 -1
  51. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +8 -3
  52. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +7 -2
  53. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +20 -3
  54. package/dist/packages/core/src/components/ProgressIndicator.d.ts +2 -5
  55. package/dist/packages/core/src/components/PromoBanner.js +23 -6
  56. package/dist/packages/core/src/components/RadioGroup.d.ts +3 -1
  57. package/dist/packages/core/src/components/RadioGroup.js +2 -2
  58. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +4 -1
  59. package/dist/packages/core/src/components/Report/ReportDocument.js +4 -1
  60. package/dist/packages/core/src/components/Section.d.ts +7 -1
  61. package/dist/packages/core/src/components/Section.js +6 -2
  62. package/dist/packages/core/src/components/Table.d.ts +1 -1
  63. package/dist/packages/core/src/components/TextField.css +2 -1
  64. package/dist/packages/core/src/components/TextField.js +10 -2
  65. package/dist/packages/core/src/components/Toggle.js +4 -1
  66. package/dist/packages/core/src/components/Tooltip/DebugTooltip.d.ts +1 -6
  67. package/dist/packages/core/src/components/Tooltip/DebugTooltip.js +4 -7
  68. package/dist/packages/core/src/components/Tooltip/Tooltip.js +11 -1
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +3 -3
  70. package/dist/packages/core/src/components/Training/TrainingBlock.d.ts +8 -3
  71. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +10 -11
  72. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +4 -4
  73. package/dist/packages/core/src/components/Training/trainingScenario.d.ts +6 -2
  74. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -1
  75. package/dist/packages/core/src/components/Word/WordTableBlock.js +6 -0
  76. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +2 -2
  77. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  78. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +1 -1
  79. package/dist/packages/core/src/components/Word/wordTableScoring.d.ts +1 -4
  80. package/dist/packages/core/src/components/Word/wordTableScoring.js +1 -4
  81. package/dist/packages/core/src/components/WordTable.d.ts +1 -1
  82. package/dist/packages/core/src/components/WordTable.js +3 -1
  83. package/dist/packages/core/src/components.d.ts +3 -0
  84. package/dist/packages/core/src/components.js +5 -0
  85. package/dist/packages/core/src/hooks/useDeviceManagement.js +11 -2
  86. package/dist/packages/core/src/hooks/useFontScale.js +3 -1
  87. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +3 -4
  88. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -4
  89. package/dist/packages/core/src/hooks/usePasswordChange.js +20 -7
  90. package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +7 -0
  91. package/dist/packages/core/src/hooks/useVisitAllPanel.js +8 -0
  92. package/dist/packages/core/src/hooks/useWordMeanings.js +3 -0
  93. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +5 -0
  94. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +23 -33
  95. package/dist/packages/core/src/i18n/locales/en.json +32 -5
  96. package/dist/packages/core/src/i18n/locales/ko.json +32 -5
  97. package/dist/packages/core/src/tokens.css +2 -1
  98. package/dist/packages/core/src/utils/CategoryData.js +2 -2
  99. package/dist/packages/core/src/utils/assets.js +7 -3
  100. package/dist/packages/core/src/utils/clipboard.js +16 -2
  101. package/dist/packages/core/src/utils/errorMessage.d.ts +2 -1
  102. package/dist/packages/core/src/utils/errorMessage.js +19 -2
  103. package/dist/packages/core/src/utils/getTrainingData.d.ts +8 -1
  104. package/dist/packages/core/src/utils/getTrainingData.js +8 -1
  105. package/dist/packages/core/src/utils/koreanSyllable.d.ts +15 -12
  106. package/dist/packages/core/src/utils/koreanSyllable.js +16 -13
  107. package/dist/packages/core/src/utils/lucideIconSet.d.ts +4 -3
  108. package/dist/packages/core/src/utils/lucideIconSet.js +4 -3
  109. package/dist/packages/core/src/utils/ttsResolver.d.ts +5 -0
  110. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +3 -3
  111. package/dist/packages/core/src/utils/wordTrainingGenerators.js +28 -7
  112. package/dist/packages/core/src/version.d.ts +1 -1
  113. package/dist/packages/core/src/version.js +1 -1
  114. package/dist/utils.cjs.js +2 -2
  115. package/package.json +4 -2
@@ -5,6 +5,8 @@ export type MergedVisit = {
5
5
  status: VisitAllStatus;
6
6
  };
7
7
  /**
8
+ * 개수 보기 옵션의 값 목록. 라벨은 한국어 고정이다 — 화면에는 훅이 돌려주는
9
+ * 번역된 `vapPageSizeOptions` 를 쓴다.
8
10
  * @public
9
11
  */
10
12
  export declare const VAP_PAGE_SIZE_OPTIONS: {
@@ -20,6 +22,11 @@ export declare function useVisitAllPanel(mergedVisits: MergedVisit[]): {
20
22
  setVapSearch: import("react").Dispatch<import("react").SetStateAction<string>>;
21
23
  vapPageSize: string;
22
24
  setVapPageSize: import("react").Dispatch<import("react").SetStateAction<string>>;
25
+ /** 번역된 개수 보기 옵션 — `VAP_PAGE_SIZE_OPTIONS` 와 값은 같고 라벨만 지금 언어로 */
26
+ vapPageSizeOptions: {
27
+ label: string;
28
+ value: string;
29
+ }[];
23
30
  vapStatus: string;
24
31
  setVapStatus: import("react").Dispatch<import("react").SetStateAction<string>>;
25
32
  vapPage: number;
@@ -1,6 +1,9 @@
1
1
  'use client';
2
2
  import { useMemo, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
3
4
  /**
5
+ * 개수 보기 옵션의 값 목록. 라벨은 한국어 고정이다 — 화면에는 훅이 돌려주는
6
+ * 번역된 `vapPageSizeOptions` 를 쓴다.
4
7
  * @public
5
8
  */
6
9
  export const VAP_PAGE_SIZE_OPTIONS = [
@@ -14,6 +17,7 @@ export const VAP_PAGE_SIZE_OPTIONS = [
14
17
  * `mergedVisits`(예정+완료+미방문)를 받아 파생 데이터를 계산.
15
18
  */
16
19
  export function useVisitAllPanel(mergedVisits) {
20
+ const { t } = useTranslation();
17
21
  const [vapSearch, setVapSearch] = useState('');
18
22
  const [vapPageSize, setVapPageSize] = useState('25');
19
23
  const [vapStatus, setVapStatus] = useState('all');
@@ -38,9 +42,13 @@ export function useVisitAllPanel(mergedVisits) {
38
42
  const vapPageItems = useMemo(() => {
39
43
  return vapFiltered.slice((vapPageClamped - 1) * Number(vapPageSize), vapPageClamped * Number(vapPageSize));
40
44
  }, [vapFiltered, vapPageClamped, vapPageSize]);
45
+ // 내보낸 상수는 모양·라벨을 그대로 둔다(소비자가 값 목록으로 쓴다). 화면용 라벨만 여기서 번역한다.
46
+ const vapPageSizeOptions = useMemo(() => VAP_PAGE_SIZE_OPTIONS.map(o => ({ ...o, label: t('common.pageSizeLabel', { n: o.value, defaultValue: o.label }) })), [t]);
41
47
  return {
42
48
  vapSearch, setVapSearch,
43
49
  vapPageSize, setVapPageSize,
50
+ /** 번역된 개수 보기 옵션 — `VAP_PAGE_SIZE_OPTIONS` 와 값은 같고 라벨만 지금 언어로 */
51
+ vapPageSizeOptions,
44
52
  vapStatus, setVapStatus,
45
53
  vapPage: vapPageClamped, setVapPage,
46
54
  vapFiltered,
@@ -44,6 +44,9 @@ export function useWordMeanings(rows, fetchMeanings) {
44
44
  memCache.set(meaningKey(row.Word, row.Homonum ?? undefined), row.Meaning);
45
45
  }
46
46
  setFetched(v => v + 1);
47
+ }).catch(() => {
48
+ // 조회 함수가 약속을 어기고 거절해도(망 끊김 등) 처리되지 않은 거절로 새지 않게 삼킨다 —
49
+ // 뜻은 있으면 좋은 것이라 빈 결과와 같게 다룬다. 캐시에 안 넣었으니 다음에 다시 받는다.
47
50
  });
48
51
  return () => { cancelled = true; };
49
52
  }, [wantedKey, fetchMeanings]);
@@ -5,11 +5,16 @@ export type { WordComposition } from './wordTrainingFilterModel';
5
5
  /** 설정 팝업 취소 시 되돌릴 필터 스냅샷 */
6
6
  export interface WordFilterSnapshot {
7
7
  composition: WordComposition;
8
+ /** 뜰 때의 대립 갯수. composition 에서 파생되므로 되돌릴 때는 읽지 않는다. */
8
9
  contrastCount: number;
9
10
  excludeNoImage: boolean;
10
11
  sameStructureOnly: boolean;
11
12
  phonemeView: 'char' | 'pron' | 'direct';
12
13
  gradeFilter: string[];
14
+ /** 품사 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
15
+ posFilter?: string[];
16
+ /** 어종 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
17
+ wordTypeFilter?: string[];
13
18
  }
14
19
  /**
15
20
  * 그룹이 노출할 설정 항목. 그룹에 속한 훈련 타입들의 CAPABILITY 합집합으로 결정.
@@ -25,6 +25,11 @@ function loadSavedFilter(groupKey) {
25
25
  catch { }
26
26
  return null;
27
27
  }
28
+ /** 저장값에서 보기 모드 복원 — 마운트 때와 groupKey 가 바뀌어 다시 읽을 때 같은 규칙을 쓴다 */
29
+ function readPhonemeView(saved) {
30
+ const v = saved?.phonemeView;
31
+ return v === 'pron' ? 'pron' : v === 'direct' ? 'direct' : 'char';
32
+ }
28
33
  /** 저장값에서 composition 복원 (레거시 choseong → cho 마이그레이션) */
29
34
  function readComposition(saved) {
30
35
  const s = saved;
@@ -52,16 +57,9 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
52
57
  // resetOnMount(단어훈련 만들기 등): 진입 시 항상 기본값에서 시작하도록 저장값을 불러오지 않는다.
53
58
  // localStorage 읽기/파싱은 마운트 시 1회만 (lazy init)
54
59
  const [saved] = useState(() => opts?.resetOnMount ? null : loadSavedFilter(groupKey));
55
- // 1~4개는 정확히 N개 차이, 5는 "5개 이상"(≥5) 버킷 — 런타임에서 threshold로 처리.
56
- // saved.contrastCount는 localStorage에 저장된 이전 세션의 값 — 과거 포맷이나 수동 편집으로
57
- // 숫자가 아닌 값이 들어있으면 Number.isFinite로 걸러 NaN이 이후 계산(비교·최소음절수 등)에
58
- // 조용히 전파되는 것을 막는다.
59
- const rawContrastCount = saved?.contrastCount;
60
- const savedContrastCount = typeof rawContrastCount === 'number' && Number.isFinite(rawContrastCount) ? rawContrastCount : 1;
61
- const [contrastCount, setContrastCount] = useState(Math.min(5, Math.max(1, savedContrastCount)));
62
60
  // 훈련 음소 그룹 보기 모드 — 'char'(글자 선택) | 'pron'(발음 선택).
63
61
  // composition 값이 모드별(글자=개별 자모, 발음=병합/대표음)로 저장되므로, 모드도 함께 persist 해야 재로드 시 일치한다.
64
- const [phonemeView, setPhonemeView] = useState(saved?.phonemeView === 'pron' ? 'pron' : saved?.phonemeView === 'direct' ? 'direct' : 'char');
62
+ const [phonemeView, setPhonemeView] = useState(readPhonemeView(saved));
65
63
  const [composition, setComposition] = useState(() => readComposition(saved));
66
64
  // 타입별 시드 composition — 한 음절 특정(requireSyllable)은 '가'(ㄱ+ㅏ+제외), 그 외는 중립.
67
65
  // 음절 수 고정(fixedSyllable)·종성 제외 고정(fixedNoCoda)이 있으면 그 값을 반영한다.
@@ -116,7 +114,6 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
116
114
  setGradeFilter([]);
117
115
  setPosFilter([]);
118
116
  setWordTypeFilter([]);
119
- setContrastCount(1);
120
117
  // seedComposition은 caps에서 파생 — resetKey와 함께 갱신되므로 deps는 resetKey만 둔다.
121
118
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
119
  }, [opts?.resetKey]);
@@ -131,13 +128,11 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
131
128
  // 훈련할 음소를 고른 슬롯(초성/모음/종성) 수 = 정답↔오답이 달라야 하는 음소의 최소 개수.
132
129
  // 예: 3슬롯 모두 선택하면 최소 3개 음소가 달라야 하므로 '다른 음소의 총 갯수'는 3부터 가능(1·2개 비활성).
133
130
  const minDiffCount = Math.max(1, [composition.cho, composition.jung, composition.jong].filter(Boolean).length);
134
- // 최소 개수 미만이 선택돼 있으면 최소값으로 자동 상향 (비활성 옵션이 선택된 채 남지 않도록)
135
- /* eslint-disable react-hooks/set-state-in-effect */
136
- useEffect(() => {
137
- if (contrastCount < minDiffCount)
138
- setContrastCount(minDiffCount);
139
- }, [minDiffCount, contrastCount]);
140
- /* eslint-enable react-hooks/set-state-in-effect */
131
+ // 대립 갯수 = 고른 칸 수(파생값). 1~4개는 정확히 N개 차이, 5는 "5개 이상"(≥5) 버킷.
132
+ // 예전에는 상태로 두고 "고른 칸 수보다 작으면 올린다"만 했다 — 고르는 UI('다른 음소의 총 갯수')가
133
+ // 걷힌 뒤로는 내릴 길이 없어, 칸 선택을 풀어도 높은 채로 남아 오답 조건이 틀어지고 그대로
134
+ // 저장됐다. 저장된 contrastCount 는 이제 읽지 않는다(저장 형식은 그대로 두어 옛 판과 호환).
135
+ const contrastCount = minDiffCount;
141
136
  // groupKey 는 마운트 때 한 번만 읽힌다(saved 가 lazy init). 그런데 저장 effect 는
142
137
  // groupKey 를 의존성에 두고 **지금 든 값**을 새 열쇠에 쓴다. 그래서 살아 있는 훅에서
143
138
  // groupKey 를 바꾸면 앞 그룹의 값이 뒤 그룹 저장소를 덮어썼다 — 실측으로 B 에 있던
@@ -154,12 +149,11 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
154
149
  return;
155
150
  if (!opts?.resetOnMount) {
156
151
  const s = loadSavedFilter(groupKey);
157
- const n = typeof s?.contrastCount === 'number' && Number.isFinite(s.contrastCount) ? s.contrastCount : 1;
158
- setContrastCount(Math.min(5, Math.max(1, n)));
159
152
  setComposition(s?.composition ?? seedComposition());
160
153
  setExcludeNoImage(s?.excludeNoImage ?? true);
161
154
  setSameStructureOnly(s?.sameStructureOnly ?? true);
162
- setPhonemeView(s?.phonemeView === 'pron' ? 'pron' : 'char');
155
+ // 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
156
+ setPhonemeView(readPhonemeView(s));
163
157
  setGradeFilter(Array.isArray(s?.gradeFilter) ? s.gradeFilter : []);
164
158
  setPosFilter(Array.isArray(s?.posFilter) ? s.posFilter : []);
165
159
  setWordTypeFilter(Array.isArray(s?.wordTypeFilter) ? s.wordTypeFilter : []);
@@ -294,8 +288,6 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
294
288
  }), [viable]);
295
289
  // 번역된 레이블 — 표시용. 내부 로직은 여전히 한국어 상수 키로 동작
296
290
  const LABELS = useMemo(() => ({
297
- // 다른 음소 갯수 — 정답과 오답이 서로 다른 음소(자모)의 총 개수 (위치 구분 없음)
298
- diffPhonemeCount: t('training.contrast.diffPhonemeCount', { defaultValue: '다른 음소의 총 갯수' }),
299
291
  // 정답 단어 구성 — 음절수/초성/모음/종성 (정답 단어에만 적용)
300
292
  composition: t('wordPairs.composition', '정답 단어 구성'),
301
293
  syllableCount: t('wordPairs.syllableCount', '음절 수'),
@@ -433,10 +425,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
433
425
  }
434
426
  return sections;
435
427
  }, [caps.choseong, caps.partOfSpeech, caps.wordType, caps.requireSyllable, caps.fixedSyllable, caps.fixedNoCoda, caps.requireCoda, minSyllables, partViable, viable, phonemeView, syllableViable, LABELS, t, infoAction, selectInfoItems, attrKeys]);
428
+ // '다른 음소의 총 갯수' 섹션은 걷혔다 — 그 선택값·선택 처리 갈래도 함께 뺀다(대립 갯수는 파생값).
436
429
  const filterSelected = {
437
- ...(caps.difficulty ? {
438
- [LABELS.diffPhonemeCount]: String(contrastCount),
439
- } : {}),
440
430
  ...((caps.choseong || caps.fixedSyllable != null) && composition.syllable != null ? { [LABELS.syllableCount]: syllableValue(composition.syllable) } : {}),
441
431
  ...(caps.choseong && composition.cho ? { [LABELS.trainOnset]: composition.cho } : {}),
442
432
  ...(caps.choseong && composition.jung ? { [LABELS.trainVowel]: composition.jung } : {}),
@@ -454,12 +444,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
454
444
  ...(caps.wordType ? { [attrKeys.wordType]: wordTypeFilter } : {}),
455
445
  };
456
446
  const handleFilterSelect = useCallback((value, sectionLabel) => {
457
- if (sectionLabel === LABELS.diffPhonemeCount) {
458
- const n = Number(value);
459
- if (n >= 1)
460
- setContrastCount(n);
461
- }
462
- else if (sectionLabel === LABELS.exclude) {
447
+ if (sectionLabel === LABELS.exclude) {
463
448
  // 다중 토글 — 클릭한 항목만 on/off
464
449
  if (value === LABELS.excludeImage) {
465
450
  setExcludeNoImage(v => !v);
@@ -540,16 +525,21 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
540
525
  setPhonemeView(next);
541
526
  }, [phonemeView]);
542
527
  // 설정 팝업 취소용 — 현재 필터 상태 스냅샷 / 스냅샷으로 복원
528
+ // 품사·어종도 함께 뜬다 — 빠져 있어서 설정 팝업에서 취소해도 그 둘만 되돌아가지 않았다.
529
+ // contrastCount 는 composition 에서 파생되므로 되돌릴 때 따로 세우지 않는다.
543
530
  const getFilterSnapshot = useCallback(() => ({
544
- composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter,
545
- }), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter]);
531
+ composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter,
532
+ }), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter]);
546
533
  const restoreFilterSnapshot = useCallback((s) => {
547
534
  setComposition(s.composition);
548
- setContrastCount(s.contrastCount);
549
535
  setExcludeNoImage(s.excludeNoImage);
550
536
  setSameStructureOnly(s.sameStructureOnly ?? true);
551
537
  setPhonemeView(s.phonemeView);
552
538
  setGradeFilter(Array.isArray(s.gradeFilter) ? s.gradeFilter : []);
539
+ if (Array.isArray(s.posFilter))
540
+ setPosFilter(s.posFilter);
541
+ if (Array.isArray(s.wordTypeFilter))
542
+ setWordTypeFilter(s.wordTypeFilter);
553
543
  }, []);
554
544
  return {
555
545
  difficulty,
@@ -82,7 +82,8 @@
82
82
  "w": "w-glide",
83
83
  "velar": "ɰ-glide"
84
84
  }
85
- }
85
+ },
86
+ "unknownError": "Unknown error"
86
87
  },
87
88
  "calendar": {
88
89
  "rangeSelect": "Select period",
@@ -281,7 +282,11 @@
281
282
  "security": {
282
283
  "title": "Security",
283
284
  "password": {
284
- "label": "Password"
285
+ "label": "Password",
286
+ "currentRequired": "Enter your current password.",
287
+ "newRequired": "Enter a new password.",
288
+ "mismatch": "The new passwords do not match.",
289
+ "changed": "Your password has been changed."
285
290
  },
286
291
  "twoFactor": {
287
292
  "label": "Two-Factor Authentication",
@@ -310,7 +315,6 @@
310
315
  "label": "Contrast",
311
316
  "type": "Contrast Type",
312
317
  "count": "Contrast Count",
313
- "diffPhonemeCount": "Total differing phonemes",
314
318
  "n": "{{n}}"
315
319
  },
316
320
  "filter": {
@@ -403,7 +407,8 @@
403
407
  "notJudged": "Not judged",
404
408
  "replay": "Replay",
405
409
  "done": "Done",
406
- "viewResult": "View Results"
410
+ "viewResult": "View Results",
411
+ "noContent": "(No content)"
407
412
  },
408
413
  "up": "Move up",
409
414
  "down": "Move down",
@@ -502,6 +507,11 @@
502
507
  "copy": "Copy Link",
503
508
  "inactive": "Inactive",
504
509
  "title": "Homework Progress"
510
+ },
511
+ "duration": {
512
+ "minSec": "{{m}}m {{s}}s",
513
+ "min": "{{m}}m",
514
+ "sec": "{{s}}s"
505
515
  }
506
516
  },
507
517
  "payment": {
@@ -672,7 +682,9 @@
672
682
  "tab": {
673
683
  "cards": "Word Table Cards",
674
684
  "tables": "Word Tables"
675
- }
685
+ },
686
+ "publishedBadge": "Published",
687
+ "sharedBadge": "Shared"
676
688
  },
677
689
  "keyWords": {
678
690
  "title": "Key Words",
@@ -1362,5 +1374,20 @@
1362
1374
  "velar": "Velar",
1363
1375
  "glottal": "Glottal"
1364
1376
  }
1377
+ },
1378
+ "bulkBar": {
1379
+ "clearSelection": "Clear selection"
1380
+ },
1381
+ "debug": {
1382
+ "showResult": "Show {{type}} result"
1383
+ },
1384
+ "badge": {
1385
+ "selected": "Selected",
1386
+ "remove": "Remove",
1387
+ "exclude": "Excluded",
1388
+ "nthSelected": "Selection {{n}}"
1389
+ },
1390
+ "loader": {
1391
+ "loading": "Loading"
1365
1392
  }
1366
1393
  }
@@ -80,7 +80,8 @@
80
80
  "w": "w활음",
81
81
  "velar": "ɰ활음"
82
82
  }
83
- }
83
+ },
84
+ "unknownError": "알 수 없는 오류"
84
85
  },
85
86
  "calendar": {
86
87
  "rangeSelect": "기간 선택",
@@ -279,7 +280,11 @@
279
280
  "security": {
280
281
  "title": "보안",
281
282
  "password": {
282
- "label": "비밀번호"
283
+ "label": "비밀번호",
284
+ "currentRequired": "현재 비밀번호를 입력하세요.",
285
+ "newRequired": "새 비밀번호를 입력하세요.",
286
+ "mismatch": "새 비밀번호가 일치하지 않습니다.",
287
+ "changed": "비밀번호가 변경되었습니다."
283
288
  },
284
289
  "twoFactor": {
285
290
  "label": "2단계 인증",
@@ -308,7 +313,6 @@
308
313
  "label": "대립",
309
314
  "type": "대립 종류",
310
315
  "count": "대립 갯수",
311
- "diffPhonemeCount": "다른 음소의 총 갯수",
312
316
  "n": "{{n}}개"
313
317
  },
314
318
  "filter": {
@@ -401,7 +405,8 @@
401
405
  "notJudged": "미판정",
402
406
  "replay": "다시하기",
403
407
  "done": "완료",
404
- "viewResult": "결과 확인"
408
+ "viewResult": "결과 확인",
409
+ "noContent": "(내용없음)"
405
410
  },
406
411
  "up": "위로",
407
412
  "down": "아래로",
@@ -500,6 +505,11 @@
500
505
  "completedFmt": "완료 {{done}}/{{total}} · {{duration}}"
501
506
  },
502
507
  "visitSuffix": "의 방문"
508
+ },
509
+ "duration": {
510
+ "minSec": "{{m}}분 {{s}}초",
511
+ "min": "{{m}}분",
512
+ "sec": "{{s}}초"
503
513
  }
504
514
  },
505
515
  "payment": {
@@ -670,7 +680,9 @@
670
680
  "publishStore": "말귀자료실에 올리기",
671
681
  "unpublishStore": "말귀자료실에서 내리기"
672
682
  },
673
- "newBadge": "새로 만든 표"
683
+ "newBadge": "새로 만든 표",
684
+ "publishedBadge": "발행됨",
685
+ "sharedBadge": "공유 중"
674
686
  },
675
687
  "keyWords": {
676
688
  "title": "주요 단어",
@@ -1360,5 +1372,20 @@
1360
1372
  "velar": "연구개음",
1361
1373
  "glottal": "성문음"
1362
1374
  }
1375
+ },
1376
+ "bulkBar": {
1377
+ "clearSelection": "선택 해제"
1378
+ },
1379
+ "debug": {
1380
+ "showResult": "{{type}} 결과보기"
1381
+ },
1382
+ "badge": {
1383
+ "selected": "선택됨",
1384
+ "remove": "제거",
1385
+ "exclude": "제외",
1386
+ "nthSelected": "{{n}}번째 선택"
1387
+ },
1388
+ "loader": {
1389
+ "loading": "불러오는 중…"
1363
1390
  }
1364
1391
  }
@@ -269,7 +269,8 @@
269
269
  --text-h0-size: 32px; --text-h0-line: 44px; --text-h0-spacing: -0.75px;
270
270
  --text-h1-size: 28px; --text-h1-line: 40px; --text-h1-spacing: -0.4px;
271
271
  --text-h2-size: 22px; --text-h2-line: 32px; --text-h2-spacing: -0.2px;
272
- --text-h3-size: 18px; --text-h3-line: 28px;
272
+ /* h3 는 태블릿에서 줄이지 않는다 — 데스크톱 20px/30px 그대로(2026-09-14 사용자 결정).
273
+ 모바일(≤767px)의 17px 은 따로 둔다. */
273
274
  --text-h4-size: 16px; --text-h4-line: 24px;
274
275
  --text-h5-size: 14px; --text-h5-line: 21px; /* 15→14: h5↔body-2 충돌 해소 */
275
276
  --text-body-1-size: 17px; --text-body-1-line: 27px; /* 16→17: h4(16px)와 분리 */
@@ -7,8 +7,8 @@ export const TRAINING_TYPES = [
7
7
  ];
8
8
  /** @public */
9
9
  export const TOPIC_TYPES = [
10
- { key: 'FOOD', label: '음식', icon: 'salad', color: '#FF8A65', subs: [{ key: 'TRKRFD', label: ' 한국 전통음식' }, { key: 'MDKRFD', label: '한국 현대음식' }, { key: 'WRLDFD', label: '세계의 음식' }, { key: 'MTRFRT', label: '식재료와 과일' }, { key: 'GEFDKL', label: '음식 상식' }] },
11
- { key: 'HLTH', label: '건강', icon: 'heart', color: '#66BB6A', subs: [{ key: 'HTETHB', label: '건강한 식습관' }, { key: 'DISILL', label: '병과 질환' }, { key: 'EXCISE', label: '운동' }, { key: 'GEHTKL', label: '건상상식' }] },
10
+ { key: 'FOOD', label: '음식', icon: 'salad', color: '#FF8A65', subs: [{ key: 'TRKRFD', label: '한국 전통음식' }, { key: 'MDKRFD', label: '한국 현대음식' }, { key: 'WRLDFD', label: '세계의 음식' }, { key: 'MTRFRT', label: '식재료와 과일' }, { key: 'GEFDKL', label: '음식 상식' }] },
11
+ { key: 'HLTH', label: '건강', icon: 'heart', color: '#66BB6A', subs: [{ key: 'HTETHB', label: '건강한 식습관' }, { key: 'DISILL', label: '병과 질환' }, { key: 'EXCISE', label: '운동' }, { key: 'GEHTKL', label: '건강상식' }] },
12
12
  { key: 'WETH', label: '날씨', icon: 'sun-medium', color: '#42A5F5', subs: [{ key: 'GESSKL', label: '계절상식' }, { key: 'SNWRAN', label: '눈과 비' }, { key: 'WTHFCT', label: '일기예보' }, { key: 'GEWTKL', label: '날씨상식' }] },
13
13
  { key: 'PRSN', label: '인물', icon: 'user-round', desc: '소음 환경 속 문장 이해 훈련', color: '#AB47BC', subs: [{ key: 'JODYPS', label: '조선시대' }, { key: 'MDKRPS', label: '한국 근현대' }, { key: 'THKDPS', label: '삼국시대' }, { key: 'WRLART', label: '세계의 예술가' }, { key: 'WRLPSN', label: '세계의 인물' }] },
14
14
  { key: 'KRCT', label: '한국문화', icon: 'bow-arrow', desc: '소음 환경 속 문장 이해 훈련', color: '#EC407A', subs: [{ key: 'KRLANG', label: '우리말' }, { key: 'TRPLCT', label: '전통놀이' }, { key: 'HERITG', label: '문화재' }, { key: 'EVTFES', label: '행사와 축제' }, { key: 'GECTKL', label: '상식' }] },
@@ -1,5 +1,8 @@
1
1
  const ABSOLUTE_URL_RE = /^(?:[a-z]+:)?\/\//i;
2
- let configuredAssetBaseUrl = '';
2
+ // globalThis 싱글톤: 다른 설정(storage·ttsConfig·imageConfig)과 같은 방식.
3
+ // 모듈 변수로 두면 CJS 묶음이 여럿일 때(utils.cjs 에서 설정, components.cjs 에서 읽기)
4
+ // 묶음마다 한 벌씩 생겨 설정이 안 보였다.
5
+ const ASSET_GLOBAL_KEY = '__ihab_core_asset_base_url__';
3
6
  function normalizeAssetBaseUrl(baseUrl) {
4
7
  const raw = String(baseUrl || '').trim();
5
8
  if (!raw)
@@ -30,11 +33,12 @@ function normalizeAssetBaseUrl(baseUrl) {
30
33
  * @public
31
34
  */
32
35
  export function setAssetBaseUrl(baseUrl) {
33
- configuredAssetBaseUrl = normalizeAssetBaseUrl(baseUrl);
36
+ ;
37
+ globalThis[ASSET_GLOBAL_KEY] = normalizeAssetBaseUrl(baseUrl);
34
38
  }
35
39
  /** @public */
36
40
  export function getAssetBaseUrl() {
37
- return configuredAssetBaseUrl;
41
+ return globalThis[ASSET_GLOBAL_KEY] ?? '';
38
42
  }
39
43
  /**
40
44
  * 에셋 경로를 절대 URL 또는 베이스 URL 기준의 경로로 변환합니다.
@@ -10,8 +10,12 @@
10
10
  export async function copyText(text) {
11
11
  // 1순위: execCommand (동기 — 제스처 유지, iframe 대응)
12
12
  if (typeof document !== 'undefined') {
13
+ // 숨긴 textarea 에 포커스를 옮기므로, 끝나면 원래 자리로 돌려준다 — 안 돌리면 복사 단추를
14
+ // 누른 뒤 키보드 포커스가 body 로 떨어져 스크린리더·Tab 이동이 처음부터 다시 시작된다.
15
+ const 원래포커스 = document.activeElement;
16
+ let ta = null;
13
17
  try {
14
- const ta = document.createElement('textarea');
18
+ ta = document.createElement('textarea');
15
19
  ta.value = text;
16
20
  ta.setAttribute('readonly', '');
17
21
  ta.style.position = 'fixed';
@@ -24,13 +28,23 @@ export async function copyText(text) {
24
28
  ta.select();
25
29
  ta.setSelectionRange(0, text.length);
26
30
  const ok = document.execCommand('copy');
27
- document.body.removeChild(ta);
28
31
  if (ok)
29
32
  return true;
30
33
  }
31
34
  catch {
32
35
  /* 폴백으로 진행 */
33
36
  }
37
+ finally {
38
+ // 성공·실패·던짐 어느 길이든 textarea 를 거두고 포커스를 되돌린다
39
+ if (ta?.parentNode)
40
+ ta.parentNode.removeChild(ta);
41
+ if (원래포커스 && 원래포커스 !== document.activeElement && typeof 원래포커스.focus === 'function' && 원래포커스.isConnected) {
42
+ try {
43
+ 원래포커스.focus({ preventScroll: true });
44
+ }
45
+ catch { /* 무시 */ }
46
+ }
47
+ }
34
48
  }
35
49
  // 2순위: Async Clipboard API
36
50
  try {
@@ -11,7 +11,8 @@
11
11
  * 곳곳에 흩어져 있어 여기 한 벌로 모은다.
12
12
  *
13
13
  * 문자열도 그대로 쓴다 — `throw '문구'` 로 던지는 자리가 있다. 끝내 못 읽는
14
- * 값(숫자·null·빈 객체·빈 문구)일 때만 fallback 을 쓴다.
14
+ * 값(숫자·null·빈 객체·빈 문구)일 때만 fallback 을 쓴다. fallback 을 주지 않으면 지금
15
+ * 언어의 '알 수 없는 오류'(`common.unknownError`), 주면 그 문구 그대로.
15
16
  *
16
17
  * 회원 앱은 `src/libs/errorUtils.ts` 가 이것을 다시 내보낸다 — 한 벌만 둔다.
17
18
  * @public
@@ -1,3 +1,16 @@
1
+ import i18n from 'i18next';
2
+ /**
3
+ * 못 읽을 때의 기본 문구 — 지금 언어로. 사전에 없거나 i18n 이 아직 안 섰으면 한국어.
4
+ *
5
+ * `src/i18n` 이 초기화하는 것도 i18next 의 **기본 인스턴스**다(묶음에서 i18next 는 external).
6
+ * 여기서 `../i18n` 을 가져오면 utils 묶음이 사전 JSON 과 init 부작용을 끌어안으므로 기본
7
+ * 인스턴스를 직접 본다. 초기화 전의 `i18n.t` 는 undefined 를 돌려준다.
8
+ */
9
+ const 알수없는오류 = () => {
10
+ const 기본 = '알 수 없는 오류';
11
+ const s = typeof i18n?.t === 'function' ? i18n.t('common.unknownError', { defaultValue: 기본 }) : undefined;
12
+ return typeof s === 'string' && s ? s : 기본;
13
+ };
1
14
  /**
2
15
  * 잡은 값에서 **사람이 읽을 수 있는 원인**을 꺼낸다.
3
16
  *
@@ -11,12 +24,16 @@
11
24
  * 곳곳에 흩어져 있어 여기 한 벌로 모은다.
12
25
  *
13
26
  * 문자열도 그대로 쓴다 — `throw '문구'` 로 던지는 자리가 있다. 끝내 못 읽는
14
- * 값(숫자·null·빈 객체·빈 문구)일 때만 fallback 을 쓴다.
27
+ * 값(숫자·null·빈 객체·빈 문구)일 때만 fallback 을 쓴다. fallback 을 주지 않으면 지금
28
+ * 언어의 '알 수 없는 오류'(`common.unknownError`), 주면 그 문구 그대로.
15
29
  *
16
30
  * 회원 앱은 `src/libs/errorUtils.ts` 가 이것을 다시 내보낸다 — 한 벌만 둔다.
17
31
  * @public
18
32
  */
19
- export function getErrorMessage(e, fallback = '알 수 없는 오류') {
33
+ export function getErrorMessage(e, fallback) {
34
+ // 기본 문구는 부를 때마다 지금 언어로 만든다(모듈 로드 때 한 번 정하면 언어 전환을 못 따른다)
35
+ if (fallback === undefined)
36
+ fallback = 알수없는오류();
20
37
  if (e instanceof Error)
21
38
  return e.message || fallback;
22
39
  if (typeof e === 'string')
@@ -26,7 +26,14 @@ export type ComputeDailySelectionOptions = {
26
26
  counts?: TrainingCounts;
27
27
  forceRefresh?: boolean;
28
28
  };
29
- /** @public */
29
+ /**
30
+ * 오늘·어제 세트를 뽑아 `TODAY_SETS`·`YESTERDAY_SETS` 에 채운다(`loadTrainingData` 가 부른다).
31
+ *
32
+ * **어제 세트는 "어제 뽑힌 세트" 가 아니다** — 캐시가 메모리에만 있어, 날이 바뀌면 어제 몫도 따로 새로 뽑는다.
33
+ * 사용자에게 보이는 오늘·어제 세트는 사용자와 날짜로 시드를 정하는 `useDailyTrainingSelection` 을 쓴다
34
+ * (같은 날 같은 사용자면 늘 같은 세트, 어제 세트도 어제와 같다).
35
+ * @public
36
+ */
30
37
  export declare function computeDailySelection({ counts, forceRefresh }?: ComputeDailySelectionOptions): {
31
38
  today: {
32
39
  [k: string]: unknown[];
@@ -18,7 +18,14 @@ const DEFAULT_TRAINING_COUNTS = { LISTEN_IN_NOISE: 3, SHORT_STORY: 2, LONG_STORY
18
18
  let _cachedCountsKey = null;
19
19
  let _cachedTodayDate = null;
20
20
  let _cachedSelection = null;
21
- /** @public */
21
+ /**
22
+ * 오늘·어제 세트를 뽑아 `TODAY_SETS`·`YESTERDAY_SETS` 에 채운다(`loadTrainingData` 가 부른다).
23
+ *
24
+ * **어제 세트는 "어제 뽑힌 세트" 가 아니다** — 캐시가 메모리에만 있어, 날이 바뀌면 어제 몫도 따로 새로 뽑는다.
25
+ * 사용자에게 보이는 오늘·어제 세트는 사용자와 날짜로 시드를 정하는 `useDailyTrainingSelection` 을 쓴다
26
+ * (같은 날 같은 사용자면 늘 같은 세트, 어제 세트도 어제와 같다).
27
+ * @public
28
+ */
22
29
  export function computeDailySelection({ counts, forceRefresh } = {}) {
23
30
  const c = Object.assign({}, DEFAULT_TRAINING_COUNTS, counts || {});
24
31
  // 오늘의 훈련은 KST 자정에 바뀌어야 한다 — UTC 날짜를 쓰면 오전 9시에 바뀐다
@@ -83,19 +83,21 @@ export declare function wordHasSyllableMatching(word: string, parts: {
83
83
  */
84
84
  export declare function isSyllablePattern(query: string): boolean;
85
85
  /**
86
- * @public 음절 패턴 미니 DSL 매칭. 쉼표(,)로 나눈 조각을 자리(음절) 단위로 자동 분해하며,
87
- * **분해된 총 자리 수는 반드시 단어의 음절 수와 같아야** 매칭된다. 한 자리는 다음 중 하나:
86
+ * @public 음절 패턴 미니 DSL 매칭. 쉼표(,)로 나눈 조각을 연속된 음절 자리들로 풀고, 그 자리들이 단어의
87
+ * **어느 위치에서든 연속으로** 맞으면 매칭된다(포함 매칭 — 단어 음절 수가 자리 수보다 많아도 된다).
88
+ * 한 자리는 다음 중 하나:
88
89
  * - 빈 자리(쉼표 사이가 비어 있음) — 무관(아무 음절이나 허용).
89
- * - 완성 음절 한 글자(예: '가') — 그 자리 음절이 정확히 이 글자와 같아야 함.
90
+ * - 완성 음절 한 글자(예: '가', '강') — 받침 없는 글자는 초성·중성만 같으면 되고(받침 무관: '수' → 수·술·숙),
91
+ * 받침 있는 글자는 정확히 같아야 함.
90
92
  * - 자음/모음 한 글자(예: 'ㄴ', 'ㅏ') — 자음이면 그 자리 초성이, 모음이면 그 자리 중성이 정확히 그
91
93
  * 자모여야 함(나머지는 무관).
92
- * - 초성·중성·종성 3자모 템플릿(예: '__ㄱ', 쉼표로 나눈 조각이 정확히 3글자일 때만) — 각 자리가 '_'면
93
- * 무관, 자모면 그 위치(초성/중성/종성)가 정확히 그 자모여야 함. 종성 자리 '_'는 받침 유무 모두 허용.
94
- * 쉼표 사이에 완성 음절·자음·모음을 이어 붙이면(예: '가ㄴ') 한 글자씩 각각 한 음절 자리로 자동 분해된다
95
- * (3글자로 붙이면 템플릿과 구분이 안 되므로 예외 — 그 경우는 쉼표로 나눠 쓴다).
96
- * 예: '가,,다' → 3음절, 첫 음절 '가' · 가운데 무관 · 끝 음절 '다'.
97
- * 예: '__ㄱ,고' → 2음절, 첫 음절 종성이 'ㄱ'(초성·중성 무관) · 끝 음절 '고'.
98
- * 예: '가ㄴ,,다'(= '가,ㄴ,,다'와 동일) → 4음절, 첫 음절 '가' · 둘째 음절 초성 'ㄴ' · 셋째 무관 · 넷째 '다'.
94
+ * - 한 음절 템플릿(조각에 '_'가 하나라도 있을 때, 1~4글자) — 초성·중성·종성 순으로 읽고, '_'나 생략한 뒤
95
+ * 자리는 무관, 자모면 그 위치가 정확히 그 자모여야 함. 종성은 겹받침 두 자모('ㄹㄱ' = ㄺ)까지.
96
+ * 예: '__ㄱ' = 종성 ㄱ, 'ㄱ_' = 초성 ㄱ, 'ㄱ_ㄱ' = 초성 ㄱ·종성 ㄱ.
97
+ * '_' 없이 완성 음절·자음·모음을 이어 붙이면(예: '가ㄴ') 한 글자씩 각각 한 음절 자리로 분해된다.
98
+ * 예: '가,,다' → 첫 음절 '가'(받침 무관) · 가운데 무관 · 끝 음절 '다'인 연속 3음절을 포함한 단어.
99
+ * 예: '__ㄱ,고' → 종성 'ㄱ'인 음절 바로 뒤에 '고'가 오는 단어(먹고·박고구마 …).
100
+ * 예: '가ㄴ,,다'(= '가,ㄴ,,다'와 동일) → 연속 4음절 '가' · 초성 'ㄴ' · 무관 · '다'.
99
101
  * 해석할 수 없는 글자(자음·모음·완성 음절이 아님)가 섞이면 그 자리는 항상 매칭 실패 처리되어 결과가
100
102
  * 0건이 된다(조용히 무관 처리하지 않는다 — 제약이 무시된 채 결과가 나오는 오해를 막기 위해).
101
103
  */
@@ -117,8 +119,9 @@ export declare function patternToComposition(pattern: string): {
117
119
  } | null;
118
120
  /**
119
121
  * @public 단어 검색 '글자 입력'의 형식 유효성 검사.
120
- * 허용: 완성 음절('가'), 초성/중성 자모('ㄱ'·'ㅏ'), 초성·중성·종성 3자모 템플릿('__ㄱ', 정확히 3글자),
121
- * 쉼표(,) 구분(빈 자리는 '무관'). 그 외(예: '_' 오용, 라틴 문자, 한 자리에 음소 4개 이상인 '___ㄱ')는 무효.
122
+ * 허용: 완성 음절('가'), 초성/중성 자모('ㄱ'·'ㅏ'), '_'가 든 한 음절 템플릿(1~4글자, 초성·중성·종성 순 —
123
+ * '__ㄱ'·'ㄱ_'·'ㄱ_ㄹㄱ'), 쉼표(,) 구분(빈 자리는 '무관'). 그 외(예: 5글자 이상 템플릿, 템플릿 자리에 맞지 않는
124
+ * 자모('ㅏ_'), 라틴 문자)는 무효.
122
125
  * @returns 유효하면 `{ ok: true }`, 아니면 `{ ok: false }`(호출부가 안내 문구를 표시).
123
126
  */
124
127
  export declare function validateSyllableSearchInput(input: string): {