@ihabdevteam/core 0.313.0 → 0.315.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.
@@ -1669,7 +1669,7 @@ export declare function copyText(text: string): Promise<boolean>;
1669
1669
  *
1670
1670
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1671
1671
  */
1672
- export declare const CORE_VERSION = "0.313.0";
1672
+ export declare const CORE_VERSION = "0.315.0";
1673
1673
 
1674
1674
  /** @public */
1675
1675
  declare const coreDefault: Record<string, unknown>;
@@ -5247,6 +5247,14 @@ export declare interface PopupProps {
5247
5247
 
5248
5248
  export declare const POS_LABEL: Record<string, string>;
5249
5249
 
5250
+ /**
5251
+ * **품사 약호 → 지금 언어의 이름**(`{ '명': 'Noun', … }`). `expandSlashLabel` 에 그대로 넘긴다.
5252
+ *
5253
+ * 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
5254
+ * @public
5255
+ */
5256
+ export declare function posLabels(t: 번역): Record<string, string>;
5257
+
5250
5258
  /**
5251
5259
  * **늦게 받는 화면 청크를 한가할 때 미리 받아 둔다.**
5252
5260
  *
@@ -10486,6 +10494,12 @@ export declare function withStepTimes(payload: unknown, shownAt?: string, answer
10486
10494
  /** text 뒤에 와/과를 붙여 반환한다. */
10487
10495
  export declare function withWaGwa(text: string): string;
10488
10496
 
10497
+ /** 열쇠 조각 표 — 못이 사전 덮임을 세는 데 쓴다. @public */
10498
+ export declare const WORD_LABEL_I18N_KEYS: {
10499
+ readonly pos: Record<string, string>;
10500
+ readonly wordType: Record<string, string>;
10501
+ };
10502
+
10489
10503
  /**
10490
10504
  * 한 계정이 통틀어 올릴 수 있는 새 제안 수. 서버가 강제하고, 화면은 남은 횟수 표시에 쓴다.
10491
10505
  *
@@ -11492,6 +11506,12 @@ export declare interface WordTrainingType {
11492
11506
  /** @public 카테고리 키 */
11493
11507
  export declare type WordTrainingTypeKey = 'SAME_SOUND_PICK' | 'SAME_OR_DIFF' | 'HEAR_AND_PICK_SAME' | 'HEAR_AND_PICK_INCLUDED' | 'STARTS_WITH_SAME_SOUND' | 'LISTEN_AND_REMEMBER_ORDER' | 'MERGE_2_SYLLABLES' | 'MERGE_3_SYLLABLES' | 'MERGE_2_PHONEMES' | 'MERGE_3_PHONEMES' | 'DROP_1_FROM_2SYLL' | 'DROP_1_FROM_3SYLL' | 'DROP_PART_FROM_COMPOUND' | 'DROP_FINAL_CONSONANT_FROM_1SYLL' | 'REMOVE_CONSONANT_AND_IDENTIFY_PHONEME' | 'CHANGE_1_SYLL_IN_2SYLL' | 'CHANGE_1_SYLL_IN_3SYLL' | 'CHANGE_1_PHONEME_IN_1SYLL' | 'CHANGE_1_PHONEME_IN_2SYLL';
11494
11508
 
11509
+ /**
11510
+ * **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
11511
+ * @public
11512
+ */
11513
+ export declare function wordTypeLabels(t: 번역): Record<string, string>;
11514
+
11495
11515
  /**
11496
11516
  * @public 틀린 문항만 — 음소별 오류를 세는 쪽이 먼저 볼 것이다.
11497
11517
  *
@@ -11499,6 +11519,11 @@ export declare type WordTrainingTypeKey = 'SAME_SOUND_PICK' | 'SAME_OR_DIFF' | '
11499
11519
  */
11500
11520
  export declare function wrongItems(items: readonly TrainingItemResult[]): TrainingItemResult[];
11501
11521
 
11522
+ /** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
11523
+ declare type 번역 = (k: string, o: {
11524
+ defaultValue: string;
11525
+ }) => string;
11526
+
11502
11527
  declare type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
11503
11528
  noiseRatio?: number;
11504
11529
  };
@@ -15,5 +15,5 @@ export default function Footer({ onTermsClick, onPrivacyClick, contact } = {}) {
15
15
  } }, children: t('footer.policy.terms', '이용약관') }) }), _jsx("li", { children: _jsx("a", { className: "footer-link", href: "#", onClick: e => { if (onPrivacyClick) {
16
16
  e.preventDefault();
17
17
  onPrivacyClick();
18
- } }, children: t('footer.policy.privacy', '개인정보 처리방침') }) })] })] })] })] }), _jsx("hr", { className: "site-footer__rule" }), _jsxs("div", { className: "site-footer__bottom", children: [_jsx("div", { className: "site-footer__brand", children: _jsx("img", { src: logoSrc, alt: "IHAB", className: "site-footer__logo" }) }), _jsxs("div", { className: "site-footer__meta", children: [_jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.ceo', '대표') }), _jsx("span", { className: "footer-meta caption-1", children: t('footer.ceoName', '한지혜') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.address', '주소') }), _jsx("span", { className: "footer-meta caption-1", children: t('footer.addressValue', '강원도 춘천시 시청길 32 해찬빌딩 3층 302호') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.support', '고객센터') }), _jsxs("span", { className: "footer-meta caption-1", children: [email, " \u00A0 ", tel] })] })] })] }), _jsx("div", { className: "site-footer__copyright footer-copyright caption-1", children: "\u00A9 IHAB, Inc. All rights reserved." })] }) }));
18
+ } }, children: t('footer.policy.privacy', '개인정보 처리방침') }) })] })] })] })] }), _jsx("hr", { className: "site-footer__rule" }), _jsxs("div", { className: "site-footer__bottom", children: [_jsx("div", { className: "site-footer__brand", children: _jsx("img", { src: logoSrc, alt: "IHAB", className: "site-footer__logo" }) }), _jsxs("div", { className: "site-footer__meta", children: [_jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.ceo', '대표') }), ' ', _jsx("span", { className: "footer-meta caption-1", children: t('footer.ceoName', '한지혜') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.address', '주소') }), ' ', _jsx("span", { className: "footer-meta caption-1", children: t('footer.addressValue', '강원도 춘천시 시청길 32 해찬빌딩 3층 302호') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.support', '고객센터') }), ' ', _jsxs("span", { className: "footer-meta caption-1", children: [email, " \u00A0 ", tel] })] })] })] }), _jsx("div", { className: "site-footer__copyright footer-copyright caption-1", children: "\u00A9 IHAB, Inc. All rights reserved." })] }) }));
19
19
  }
@@ -74,5 +74,5 @@ export default function WordDataUpdatePopup({ title, progress, onDone, onRetry,
74
74
  ? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
75
75
  : isError
76
76
  ? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
77
- : _jsx(Button, { htmlType: "button", size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
77
+ : _jsx(Button, { htmlType: "button", size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount), count: wordCount }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
78
78
  }
@@ -229,9 +229,11 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
229
229
  : null;
230
230
  /* **안-모음: 모을 값이 없다**(2026-10-10 조사에서 한 번 모아 봤다가 되돌림). `넘침재기` 는
231
231
  `scrollWidth > clientWidth` 하나를 **불리언**으로 넣는데, 같은 값이면 React 가 그 자리에서
232
- 멈춘다 — 몰려 와도 다시 그리지 않는다. 그리고 바로 위 `requestAnimationFrame(() => 넘침재기())`
233
- 가 *자리잡은 뒤 한 번 더* 재는 일을 이미 하고, `test/tableScrollRegion.test.tsx` 가 **이 배선을
234
- 글자로 박아** 두었다(옛 결함을 지키는 못이다). 모으면 그 못이 지키던 모양이 바뀐다. */
232
+ 멈춘다 — resize 가 몰려 와도 **다시 그리지 않는다.** 읽는 것도 그 두 값뿐이다.
233
+
234
+ (처음엔 *"못이 이 배선을 글자로 박아 두었다"* 도 까닭으로 적었는데, 그 못을 **꼴을 재는 쪽으로
235
+ 고쳤으니** 이제 그 까닭은 없다 — 지운다. 남는 까닭은 위의 하나뿐이고, 그것만으로 충분하다.
236
+ **까닭이 틀린 주석은 다음 사람에게 잘못된 값을 준다.**) */
235
237
  window.addEventListener('resize', 넘침재기);
236
238
  return () => {
237
239
  if (다음틀 != null)
@@ -19,6 +19,10 @@ export function getNoiseLevelOptions(t) {
19
19
  /** @public */
20
20
  export function getNoiseTypeOptions(t) {
21
21
  return [
22
+ /* **폭죽은 일부러 둔다 — 사장님 결정 2026-10-10.** (116) 에서 훈련 갈래의 `party-popper` 를 걷으며
23
+ *"여기도 같은 꼴 아닌가"* 로 올렸고(형제 셋은 `car`·`message-square-more`·`coffee` 로 **그 자리를
24
+ 그린다**), 넷(`waves` · `volume-2` · `wind` · 그대로) 중 **그대로**를 고르셨다.
25
+ **같은 꼴이라고 같은 결정이 아니다** — 고치지 말 것. 바꾸려면 다시 물어야 한다. */
22
26
  { label: t('training.noise.type.default', '기본'), value: "defaultNoise", icon: "party-popper" },
23
27
  { label: t('training.noise.type.street', '길거리'), value: "street", icon: "car" },
24
28
  { label: t('training.noise.type.buzz', '와글와글'), value: "buzz", icon: "message-square-more" },
@@ -105,8 +105,11 @@ export default function TrainingBlockButton(props) {
105
105
  catch (_) { }
106
106
  // 아이콘 호버·팝업 툴팁 — 카테고리가 있으면 카테고리, 없으면 훈련 타입 이름, 그마저 없으면 제목.
107
107
  const popupIconTooltip = subName ? `${mainName} / ${subName}` : (mainName || type?.label || itemTitle);
108
- const q = (n) => t('training.countQuestions', { n });
109
- const s = (n) => t('training.countSentences', { n });
108
+ /* **`count` 도 함께 넘긴다** — 영어 복수형을 i18next 가 가르는 열쇠는 `count` 다((117), 2026-10-10).
109
+ `{{n}}` 만 넘기면 화면에 `1 questions` 가 뜬다(말귀 실측: 오늘의 듣기 카드 「국방 — 1 questions」).
110
+ 글에 쓰는 것은 `{{n}}`(자리를 지키는 이름), 가르는 것은 `count` — 두 이름이 한 값을 받는다. */
111
+ const q = (n) => t('training.countQuestions', { n, count: n });
112
+ const s = (n) => t('training.countSentences', { n, count: n });
110
113
  // 문항수가 정해지지 않으면(계산 불가) 가짜 기본값을 보이지 않도록 빈 값으로 둔다.
111
114
  let countLabel = '';
112
115
  try {
@@ -21,7 +21,7 @@ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchT
21
21
  ihab 은 **흰 판을 마스크로 쓴다**(사용자 지시 2026-09-30: 글자색을 입힌다) — 그쪽은
22
22
  `WordTable.css` 가 제 곁의 파일을 직접 가리키므로 여기서 안 들여온다. */
23
23
  import 말귀로고 from '../logo/Logo_speechear_kr.svg';
24
- import { WORD_TABLE_PAPER, gridFor, CUT_CLASS, POS_LABEL, WORD_TYPE_LABEL, expandSlashLabel, dotDashStyle, REF_W_BOTH, REF_W_TEXT_ONLY, REF_W_IMAGE_ONLY, REF_H_BOTH, REF_H_TEXT_ONLY, REF_H_IMAGE_ONLY, } from './wordTableLayout';
24
+ import { WORD_TABLE_PAPER, gridFor, CUT_CLASS, posLabels, wordTypeLabels, expandSlashLabel, dotDashStyle, REF_W_BOTH, REF_W_TEXT_ONLY, REF_W_IMAGE_ONLY, REF_H_BOTH, REF_H_TEXT_ONLY, REF_H_IMAGE_ONLY, } from './wordTableLayout';
25
25
  import { useFitScale } from './useFitScale';
26
26
  import { claimSound } from './Training/currentSound';
27
27
  import { playOneShot } from './Training/oneShotSound';
@@ -78,9 +78,9 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
78
78
  arr.push(t('wordTable.info.syllables', { n: wordInfo.syllables, defaultValue: '음절 수: {{n}}음절' }));
79
79
  // 축약 코드를 전체 표기로 — 복합값('명/관' → '명사/관형사')도 각각 펼친다(단어 관리 표와 동일 규칙).
80
80
  if (wordInfo.pos)
81
- arr.push(t('wordTable.info.pos', { v: expandSlashLabel(wordInfo.pos, 품사이름(t)), defaultValue: '품사: {{v}}' }));
81
+ arr.push(t('wordTable.info.pos', { v: expandSlashLabel(wordInfo.pos, posLabels(t)), defaultValue: '품사: {{v}}' }));
82
82
  if (wordInfo.wordType)
83
- arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, 어종이름(t)), defaultValue: '유형: {{v}}' }));
83
+ arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, wordTypeLabels(t)), defaultValue: '유형: {{v}}' }));
84
84
  return arr.length ? arr : null;
85
85
  }, [wordInfo, t]);
86
86
  const 소리열쇠 = pron || label;
@@ -167,19 +167,6 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
167
167
  것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
168
168
  ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => { e.stopPropagation(); 정보톡.touchPress(); } }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
169
169
  }
170
- // ── WordTable (표 디자인) ──────────────────────────────────────
171
- /* 품사·어종 약호 → 지금 언어의 이름. 사전에 없으면 원본 한국어 이름표(POS_LABEL·WORD_TYPE_LABEL) */
172
- const 품사열쇠 = {
173
- '명': 'noun', '동': 'verb', '형': 'adjective', '부': 'adverb', '수': 'numeral', '관': 'determiner', '감': 'interjection',
174
- '조': 'particle', '접': 'affix', '의': 'dependentNoun', '대': 'pronoun', '보동': 'auxVerb', '보형': 'auxAdjective',
175
- };
176
- const 어종열쇠 = { '고유': 'native', '한자': 'sino', '외래': 'foreign', '혼종': 'mixed' };
177
- function 품사이름(t) {
178
- return Object.fromEntries(Object.entries(POS_LABEL).map(([k, v]) => [k, 품사열쇠[k] ? t(`wordTable.pos.${품사열쇠[k]}`, { defaultValue: v }) : v]));
179
- }
180
- function 어종이름(t) {
181
- return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, 어종열쇠[k] ? t(`wordSearch.filter.type.${어종열쇠[k]}`, { defaultValue: v }) : v]));
182
- }
183
170
  /**
184
171
  * 단어표(표 디자인) — A4 종이를 컨테이너에 맞춰 축소(scale)하고,
185
172
  * 칸 그리드와 단어 셀을 그린다. 컨트롤(필터·저장·인쇄)은 포함하지 않는다.
@@ -66,6 +66,27 @@ export declare const CUT_CLASS: Record<WordTableCutLine, string>;
66
66
  export declare const POS_LABEL: Record<string, string>;
67
67
  /** 어종(단어 유형) 약호 → 전체 표기. 데이터의 Class 컬럼은 축약형('고유'/'한자'/'외래'/'혼종')을 쓴다. */
68
68
  export declare const WORD_TYPE_LABEL: Record<string, string>;
69
+ /** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
70
+ type 번역 = (k: string, o: {
71
+ defaultValue: string;
72
+ }) => string;
73
+ /**
74
+ * **품사 약호 → 지금 언어의 이름**(`{ '명': 'Noun', … }`). `expandSlashLabel` 에 그대로 넘긴다.
75
+ *
76
+ * 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
77
+ * @public
78
+ */
79
+ export declare function posLabels(t: 번역): Record<string, string>;
80
+ /**
81
+ * **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
82
+ * @public
83
+ */
84
+ export declare function wordTypeLabels(t: 번역): Record<string, string>;
85
+ /** 열쇠 조각 표 — 못이 사전 덮임을 세는 데 쓴다. @public */
86
+ export declare const WORD_LABEL_I18N_KEYS: {
87
+ readonly pos: Record<string, string>;
88
+ readonly wordType: Record<string, string>;
89
+ };
69
90
  /**
70
91
  * 슬래시로 묶인 복합 약호(예: '명/관', '동/보동')를 라벨맵으로 각각 펼쳐 다시 '/'로 잇는다.
71
92
  * 라벨맵에 없는 조각은 원문 그대로 둔다. 빈 문자열은 빈 문자열 그대로 반환.
@@ -78,3 +99,4 @@ export declare const REF_W_IMAGE_ONLY = 280;
78
99
  export declare const REF_H_BOTH = 400;
79
100
  export declare const REF_H_TEXT_ONLY = 120;
80
101
  export declare const REF_H_IMAGE_ONLY = 280;
102
+ export {};
@@ -117,6 +117,42 @@ export const POS_LABEL = {
117
117
  export const WORD_TYPE_LABEL = {
118
118
  '고유': '고유어', '한자': '한자어', '외래': '외래어', '혼종': '혼종어',
119
119
  };
120
+ /* ── 품사·어종을 **지금 언어로** ─────────────────────────────────────────
121
+ 위 두 표는 **한국어**다(자료의 약호를 한국어 전체 표기로 펴는 표). 화면에 그릴 때는 이것을 그대로
122
+ 쓰면 안 된다 — 영어 화면에서 한 줄 안에 섞인다(말귀 실측 2026-10-10, 낱말 제안 카드:
123
+ `Grade 3 · 명사 · 혼종어 · … · Homonym 1` — 등급·동음이의는 영어인데 품사·어종만 한국어).
124
+
125
+ **그 번역은 `WordTable` 안에만 숨어 있었다.** 그래서 밖에서는 `POS_LABEL` 을 날로 쓸 수밖에
126
+ 없었고(공개된 것이 그것뿐이었다), 앱이 그렇게 썼다. 번역을 여기로 올려 **같이 공개한다** —
127
+ 같은 결정이 두 자리에 적히지 않게 한다((120), 2026-10-10).
128
+
129
+ 두 날 표(`POS_LABEL`·`WORD_TYPE_LABEL`)는 **그대로 내보낸다** — 쓰는 데가 있고, 한국어 전체
130
+ 표기가 필요한 자리(인쇄 등)도 있다. 다만 **화면에 그릴 때는 아래 둘을 쓴다.** */
131
+ /** 약호 → i18n 열쇠 조각. 자료의 `POS` 칸은 홑 코드 열셋(+ `명/관` 같은 슬래시 묶음)이다. */
132
+ const POS_I18N_KEY = {
133
+ '명': 'noun', '동': 'verb', '형': 'adjective', '부': 'adverb', '수': 'numeral', '관': 'determiner', '감': 'interjection',
134
+ '조': 'particle', '접': 'affix', '의': 'dependentNoun', '대': 'pronoun', '보동': 'auxVerb', '보형': 'auxAdjective',
135
+ };
136
+ /** 자료의 `Class` 칸은 넷이다(라이브 전수: 한자 9,361 · 고유 8,458 · 혼종 4,227 · 외래 954). */
137
+ const WORD_TYPE_I18N_KEY = { '고유': 'native', '한자': 'sino', '외래': 'foreign', '혼종': 'mixed' };
138
+ /**
139
+ * **품사 약호 → 지금 언어의 이름**(`{ '명': 'Noun', … }`). `expandSlashLabel` 에 그대로 넘긴다.
140
+ *
141
+ * 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
142
+ * @public
143
+ */
144
+ export function posLabels(t) {
145
+ return Object.fromEntries(Object.entries(POS_LABEL).map(([k, v]) => [k, POS_I18N_KEY[k] ? t(`wordTable.pos.${POS_I18N_KEY[k]}`, { defaultValue: v }) : v]));
146
+ }
147
+ /**
148
+ * **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
149
+ * @public
150
+ */
151
+ export function wordTypeLabels(t) {
152
+ return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, WORD_TYPE_I18N_KEY[k] ? t(`wordSearch.filter.type.${WORD_TYPE_I18N_KEY[k]}`, { defaultValue: v }) : v]));
153
+ }
154
+ /** 열쇠 조각 표 — 못이 사전 덮임을 세는 데 쓴다. @public */
155
+ export const WORD_LABEL_I18N_KEYS = { pos: POS_I18N_KEY, wordType: WORD_TYPE_I18N_KEY };
120
156
  /**
121
157
  * 슬래시로 묶인 복합 약호(예: '명/관', '동/보동')를 라벨맵으로 각각 펼쳐 다시 '/'로 잇는다.
122
158
  * 라벨맵에 없는 조각은 원문 그대로 둔다. 빈 문자열은 빈 문자열 그대로 반환.
@@ -219,7 +219,7 @@ export { default as TrainingPagePopupTitle } from './components/Training/Trainin
219
219
  export { default as FilterPanel, FILTER_ALL_VALUE } from './components/FilterPanel';
220
220
  export type { FilterPanelProps, FilterPanelSection, FilterPanelItem } from './components/FilterPanel';
221
221
  export { default as WordTable, WORD_TABLE_PAPER } from './components/WordTable';
222
- export { POS_LABEL, WORD_TYPE_LABEL, expandSlashLabel, printWordTable } from './components/wordTableLayout';
222
+ export { POS_LABEL, WORD_TYPE_LABEL, posLabels, wordTypeLabels, WORD_LABEL_I18N_KEYS, expandSlashLabel, printWordTable } from './components/wordTableLayout';
223
223
  export type { WordTableProps, WordTableOrientation, WordTableContentType, WordTableCutLine, WordTableEditEntry } from './components/WordTable';
224
224
  export { useFitScale } from './components/useFitScale';
225
225
  export type { UseFitScaleResult } from './components/useFitScale';
@@ -161,7 +161,7 @@ export { default as FilterPanel, FILTER_ALL_VALUE } from './components/FilterPan
161
161
  export { default as WordTable, WORD_TABLE_PAPER } from './components/WordTable';
162
162
  // 라벨 맵·헬퍼는 순수 상수/함수라 컴포넌트 파일(WordTable.tsx)을 거치지 않고 직접 내보낸다
163
163
  // (컴포넌트 파일이 non-component를 export하면 fast refresh가 깨진다).
164
- export { POS_LABEL, WORD_TYPE_LABEL, expandSlashLabel, printWordTable } from './components/wordTableLayout';
164
+ export { POS_LABEL, WORD_TYPE_LABEL, posLabels, wordTypeLabels, WORD_LABEL_I18N_KEYS, expandSlashLabel, printWordTable } from './components/wordTableLayout';
165
165
  export { useFitScale } from './components/useFitScale';
166
166
  export { default as WordSearchResultBlock } from './components/Word/WordSearchResultBlock';
167
167
  export { default as PhonemeSelectBlock } from './components/Word/PhonemeSelectBlock';
@@ -30,9 +30,11 @@
30
30
  "reset": "Reset",
31
31
  "update": "Update",
32
32
  "download": "Download",
33
- "likeCount": "{{count}} likes",
33
+ "likeCount_one": "{{count}} like",
34
+ "likeCount_other": "{{count}} likes",
34
35
  "selectedOfMax": "{{count}} of {{max}}",
35
- "downloadCount": "{{count}} downloads",
36
+ "downloadCount_one": "{{count}} download",
37
+ "downloadCount_other": "{{count}} downloads",
36
38
  "descriptionFor": "About {{name}}",
37
39
  "search": "Search",
38
40
  "empty": "No data.",
@@ -124,7 +126,8 @@
124
126
  "noVisit": "No Show"
125
127
  },
126
128
  "periodSelect": "Period unit",
127
- "visitCount": "{{count}} visit(s)",
129
+ "visitCount_one": "{{count}} visit",
130
+ "visitCount_other": "{{count}} visits",
128
131
  "statusCount": "{{count}} {{label}}"
129
132
  },
130
133
  "passCard": {
@@ -196,7 +199,8 @@
196
199
  "more": "More",
197
200
  "moreMenu": "More menu",
198
201
  "badgeLabel": "{{label}}, {{count}}",
199
- "badgeInsideLabel": "{{label}}, {{count}} notifications inside"
202
+ "badgeInsideLabel_one": "{{label}}, {{count}} notification inside",
203
+ "badgeInsideLabel_other": "{{label}}, {{count}} notifications inside"
200
204
  },
201
205
  "settings": {
202
206
  "language": {
@@ -356,8 +360,10 @@
356
360
  "sameStructure": "Only same phoneme structure"
357
361
  },
358
362
  "noQuestions": "No questions available.",
359
- "countQuestions": "{{n}} questions",
360
- "countSentences": "{{n}} sentences",
363
+ "countQuestions_one": "{{n}} question",
364
+ "countQuestions_other": "{{n}} questions",
365
+ "countSentences_one": "{{n}} sentence",
366
+ "countSentences_other": "{{n}} sentences",
361
367
  "comingSoon": "Coming Soon",
362
368
  "nextQuestion": "Next",
363
369
  "noiseRatioTitle": "Noise Ratio",
@@ -789,7 +795,8 @@
789
795
  "title": "Load Saved Word Table",
790
796
  "empty": "No saved word tables.",
791
797
  "loadButton": "Load",
792
- "itemCount": "{{count}} words",
798
+ "itemCount_one": "{{count}} word",
799
+ "itemCount_other": "{{count}} words",
793
800
  "deleteAriaLabel": "Delete {{name}}",
794
801
  "viewLabel": "View as",
795
802
  "sortLabel": "Sort by",
@@ -841,14 +848,18 @@
841
848
  "none": "Tap ○ · △ · ✕ under a cell to mark it. With a mouse they appear when you hover the cell; with a keyboard, when focus reaches it.",
842
849
  "tally": "{{marked}} scored · ○ {{o}} · △ {{triangle}} · ✕ {{x}} · {{rate}}%",
843
850
  "save": "Save score",
844
- "saved": "Saved {{count}} marks for {{name}}.",
851
+ "saved_one": "Saved {{count}} mark for {{name}}.",
852
+ "saved_other": "Saved {{count}} marks for {{name}}.",
845
853
  "reviewTitle": "Create a review table?",
846
- "reviewMessage": "Builds a new table from the {{count}} cells {{name}} got wrong or partial. Correct ones are left out.",
854
+ "reviewMessage_one": "Builds a new table from the {{count}} cell {{name}} got wrong or partial. Correct ones are left out.",
855
+ "reviewMessage_other": "Builds a new table from the {{count}} cells {{name}} got wrong or partial. Correct ones are left out.",
847
856
  "reviewConfirm": "Create review table",
848
857
  "reviewDescription": "Cells marked wrong or partial in “{{name}}” — {{count}} of them.",
849
- "reviewCreated": "Created “{{name}}” — {{count}} cells.",
858
+ "reviewCreated_one": "Created “{{name}}” — {{count}} cell.",
859
+ "reviewCreated_other": "Created “{{name}}” — {{count}} cells.",
850
860
  "discardTitle": "Discard this scoring?",
851
- "discardMessage": "You marked {{count}} cells but have not saved. Leaving discards them.",
861
+ "discardMessage_one": "You marked {{count}} cell but have not saved. Leaving discards it.",
862
+ "discardMessage_other": "You marked {{count}} cells but have not saved. Leaving discards them.",
852
863
  "discardConfirm": "Discard and leave"
853
864
  },
854
865
  "info": {
@@ -1012,7 +1023,8 @@
1012
1023
  "copy": "Copy",
1013
1024
  "loading": "Loading…",
1014
1025
  "empty": "No links created yet.",
1015
- "uses": "Used {{count}} times",
1026
+ "uses_one": "Used {{count}} time",
1027
+ "uses_other": "Used {{count}} times",
1016
1028
  "expiresAt": "Expires: {{date}}",
1017
1029
  "createdAt": "Created: {{date}}",
1018
1030
  "confirmDelete": "Delete this link?",
@@ -1230,7 +1242,7 @@
1230
1242
  "address": "Address",
1231
1243
  "support": "Support",
1232
1244
  "ceoName": "Ji-hye Han",
1233
- "addressValue": "302, 3F, Haechan Bldg, 32 Siocheong-gil, Chuncheon, Gangwon, Korea"
1245
+ "addressValue": "302, 3F, Haechan Bldg, 32 Sicheong-gil, Chuncheon, Gangwon, Korea"
1234
1246
  },
1235
1247
  "landing": {
1236
1248
  "trainingPhone": {
@@ -1274,7 +1286,8 @@
1274
1286
  "phaseWordDownload": "Download Word Data",
1275
1287
  "phaseImageCheck": "Check Images",
1276
1288
  "phasePairAnalysis": "Analyze Word Pairs",
1277
- "downloadingCount": "Downloading {{n}} words",
1289
+ "downloadingCount_one": "Downloading {{n}} word",
1290
+ "downloadingCount_other": "Downloading {{n}} words",
1278
1291
  "checkingPage": "Checking page {{page}} list",
1279
1292
  "imageCount": "{{done}} / {{total}} items"
1280
1293
  },
@@ -1334,7 +1347,8 @@
1334
1347
  "countSuffix": " sessions"
1335
1348
  },
1336
1349
  "time": {
1337
- "countLabel": "{{n}} sessions",
1350
+ "countLabel_one": "{{n}} session",
1351
+ "countLabel_other": "{{n}} sessions",
1338
1352
  "empty": "No training records"
1339
1353
  },
1340
1354
  "daily": {
@@ -1397,7 +1411,8 @@
1397
1411
  "colTrend": "Change",
1398
1412
  "colRuns": "Runs",
1399
1413
  "unitItem": " items",
1400
- "skippedOnly": "{{n}} older record(s) were excluded — they do not say what was answered.",
1414
+ "skippedOnly_one": "{{n}} older record was excluded — it does not say what was answered.",
1415
+ "skippedOnly_other": "{{n}} older records were excluded — they do not say what was answered.",
1401
1416
  "summaryLabel": "Recorded",
1402
1417
  "summaryValue": "{{items}} items · {{pending}} still missed",
1403
1418
  "nextLabel": "Next to work on",
@@ -1407,7 +1422,8 @@
1407
1422
  "typeClear": "All caught up",
1408
1423
  "typePending": "{{pending}} of {{total}} left",
1409
1424
  "flat": "no change",
1410
- "runs": "{{count}} run(s)",
1425
+ "runs_one": "{{count}} run",
1426
+ "runs_other": "{{count}} runs",
1411
1427
  "avgDuration": "{{sec}}s avg"
1412
1428
  },
1413
1429
  "table": {
@@ -1,6 +1,8 @@
1
1
  /** @public */
2
2
  export const TRAINING_TYPES = [
3
- { key: "LISTEN_IN_NOISE", label: "소음 하 문장 듣기", icon: "party-popper", desc: "다양한 소음 환경에서 문장 청력을 훈련합니다." },
3
+ /* 아이콘은 **`categories.ts` 의 `DEFAULT_TRAINING_TYPES` 와 같아야 한다** — 말귀는 그쪽 하나만 짚었는데
4
+ 세어 보니 **둘**이었고, 화면에 나가는 것은 이쪽(`translateTrainingTypes`)이다. (116), 2026-10-10. */
5
+ { key: "LISTEN_IN_NOISE", label: "소음 하 문장 듣기", icon: "audio-lines", desc: "다양한 소음 환경에서 문장 청력을 훈련합니다." },
4
6
  { key: "SHORT_STORY", label: "짧은 이야기 듣기", icon: "message-square", desc: "짧은 이야기를 듣고 기억하여 문제를 풀어봅니다." },
5
7
  { key: "LONG_STORY", label: "긴 이야기 듣기", icon: "message-square-text", desc: "긴 이야기를 듣고 기억하여 다수의 문제를 풀어봅니다." },
6
8
  { key: "ORDERING_SENTENCE", label: "문장 순서화 하기", icon: "align-start-vertical", desc: "여러개의 짧은 문장을 듣고 기억하여 그 순서에 맞게 배열합니다." },
@@ -33,7 +33,10 @@ const DEFAULT_TOPIC_TYPES = [
33
33
  { key: 'SPRT', label: '스포츠', icon: 'trophy', color: '#FFCA28', subs: [{ key: 'INVDSP', label: '개인경기' }, { key: 'MARART', label: '격투기' }, { key: 'TEAMSP', label: '단체경기' }, { key: 'FOOTBL', label: '축구' }, { key: 'GESPKL', label: '스포츠 상식' }] },
34
34
  ];
35
35
  const DEFAULT_TRAINING_TYPES = [
36
- { key: 'LISTEN_IN_NOISE', label: '소음 하 문장 듣기', icon: 'party-popper', desc: '소음 환경 속 문장 이해 훈련' },
36
+ /* 아이콘은 **`CategoryData.ts` 의 `TRAINING_TYPES` 와 같아야 한다** — 같은 결정이 두 목록에 적혀 있다
37
+ (못: `test/trainingTypeIconsAgree.test.ts`). (116) 에서 `party-popper`(🎉) 를 걷었다: 자리를 채워 둔
38
+ 값이 두 앱 숙제 카드에 그대로 나갔고, 네 갈래 중 **가장 많이 쓰이는** 갈래라 목록이 폭죽밭이었다. */
39
+ { key: 'LISTEN_IN_NOISE', label: '소음 하 문장 듣기', icon: 'audio-lines', desc: '소음 환경 속 문장 이해 훈련' },
37
40
  { key: 'SHORT_STORY', label: '짧은 이야기 듣기', icon: 'message-square', desc: '짧은 이야기 + 4문제' },
38
41
  { key: 'LONG_STORY', label: '긴 이야기 듣기', icon: 'message-square-text', desc: '긴 이야기 심화 훈련' },
39
42
  { key: 'ORDERING_SENTENCE', label: '문장 순서화 하기', icon: 'align-start-vertical', desc: '순서 배열 훈련' },
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.313.0";
49
+ export declare const CORE_VERSION = "0.315.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.313.0';
49
+ export const CORE_VERSION = '0.315.0';