@ihabdevteam/core 0.318.0 → 0.322.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 (76) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/dist/components.cjs.js +22 -22
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +188 -188
  7. package/dist/ihabdevteam-core.d.ts +144 -63
  8. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  9. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  10. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  11. package/dist/packages/core/src/components/PanelRow.js +2 -2
  12. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  13. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  14. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -3
  15. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  16. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -3
  17. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  18. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.css +19 -0
  19. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.d.ts +16 -1
  20. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +8 -6
  21. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -2
  22. package/dist/packages/core/src/components/Training/TrainingCardGroup.d.ts +20 -1
  23. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -2
  24. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +15 -0
  25. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +4 -4
  26. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +16 -1
  27. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +2 -2
  28. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +2 -2
  29. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +14 -1
  30. package/dist/packages/core/src/components/Training/TrainingStepConfirm.d.ts +3 -1
  31. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +42 -30
  32. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +2 -2
  33. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  34. package/dist/packages/core/src/components/Training/oneShotSound.js +3 -3
  35. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +60 -10
  36. package/dist/packages/core/src/components/Training/trainingMissStats.js +2 -2
  37. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +9 -9
  38. package/dist/packages/core/src/components/Word/WordTableBlock.css +25 -0
  39. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  40. package/dist/packages/core/src/components/Word/wordScoreStats.js +2 -2
  41. package/dist/packages/core/src/components/Word/wordTableRecommend.js +2 -2
  42. package/dist/packages/core/src/components/wordTableLayout.d.ts +5 -3
  43. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  44. package/dist/packages/core/src/hooks/inputModality.js +1 -1
  45. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  46. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  47. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  48. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  49. package/dist/packages/core/src/hooks/useTablePaging.d.ts +16 -5
  50. package/dist/packages/core/src/hooks/useTablePaging.js +20 -9
  51. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +10 -1
  52. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +11 -2
  53. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +8 -4
  54. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +44 -7
  55. package/dist/packages/core/src/i18n/locales/en.json +8 -5
  56. package/dist/packages/core/src/i18n/locales/ko.json +8 -5
  57. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  58. package/dist/packages/core/src/utils/devWarn.js +5 -5
  59. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  60. package/dist/packages/core/src/utils/filterPanelSummary.js +4 -4
  61. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  62. package/dist/packages/core/src/utils/navScroll.js +4 -4
  63. package/dist/packages/core/src/utils/phonemePairSearch.js +10 -3
  64. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  65. package/dist/packages/core/src/utils/pressable.js +3 -3
  66. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  67. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  68. package/dist/packages/core/src/utils/textSort.d.ts +15 -5
  69. package/dist/packages/core/src/utils/textSort.js +19 -9
  70. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +37 -0
  71. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +60 -3
  72. package/dist/packages/core/src/utils/wordTrainingPool.js +3 -2
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +7 -3
  76. package/package.json +2 -1
@@ -672,7 +672,16 @@ export declare function buildDropContrastProblem(word: string, comp: Composition
672
672
  */
673
673
  export declare function buildDropFinalConsonantProblem(word: string, _comp: CompositionFilter | null, pronMap: Map<string, string>, _pv: 'char' | 'pron', _ws: Set<string>, _dropPos?: number | null): TrainingProblem | null;
674
674
 
675
- /** @public 등급(1~4등급) FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다. */
675
+ /**
676
+ * @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
677
+ *
678
+ * **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
679
+ * 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
680
+ * 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
681
+ *
682
+ * 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
683
+ * 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
684
+ */
676
685
  export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
677
686
  allLabel?: string;
678
687
  labelAction?: ReactNode;
@@ -1669,7 +1678,7 @@ export declare function copyText(text: string): Promise<boolean>;
1669
1678
  *
1670
1679
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1671
1680
  */
1672
- export declare const CORE_VERSION = "0.318.0";
1681
+ export declare const CORE_VERSION = "0.322.0";
1673
1682
 
1674
1683
  /** @public */
1675
1684
  declare const coreDefault: Record<string, unknown>;
@@ -2030,7 +2039,7 @@ export declare interface DeviceRow {
2030
2039
  worn: boolean;
2031
2040
  }
2032
2041
 
2033
- declare type DeviceSide = '좌' | '우';
2042
+ declare type DeviceSide = 'left' | 'right';
2034
2043
 
2035
2044
  export declare function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): JSX.Element;
2036
2045
 
@@ -2222,10 +2231,10 @@ export declare interface DropdownProps {
2222
2231
  * @public 소음이 **들리는 크기**(0~2, 원본 소음 파일 기준) = mainVolume × 비율. 켜짐 여부는 보지
2223
2232
  * 않는다. 결과 기록·파형이 쓴다. 소음 `<audio>` 에 거는 값은 `noiseElementVolume`.
2224
2233
  */
2225
- export declare function effectiveNoiseVolume(settings: 비율설정): number;
2234
+ export declare function effectiveNoiseVolume(settings: SpeechVolumeSettings): number;
2226
2235
 
2227
2236
  /** @public 문제 소리 볼륨(0~1) = mainVolume. 비율과 상관없다. */
2228
- export declare function effectiveSpeechVolume(settings: 비율설정): number;
2237
+ export declare function effectiveSpeechVolume(settings: SpeechVolumeSettings): number;
2229
2238
 
2230
2239
  /** @public */
2231
2240
  export declare function EffectPartyPopper({ active, count, duration, strength, // multiplier for speed/spread
@@ -2918,6 +2927,16 @@ export declare function getTTSUrlResolver(): ((text: string) => string | Promise
2918
2927
  /** @public 단어 풀 조회 */
2919
2928
  export declare function getWordPool(): WordPool | null;
2920
2929
 
2930
+ /**
2931
+ * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
2932
+ *
2933
+ * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
2934
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
2935
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
2936
+ * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
2937
+ */
2938
+ export declare function gradeDescriptions(t: TFunction): string[];
2939
+
2921
2940
  /** @public 이 타입에 '문제(정답) 단어' 구조 제약이 있는가(편집기 카운트/후보 좁히기 판단용). */
2922
2941
  export declare function hasQuestionWordConstraint(typeKey: WordTrainingTypeKey): boolean;
2923
2942
 
@@ -3170,20 +3189,20 @@ declare interface IsSelectionCorrectParams {
3170
3189
  * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
3171
3190
  *
3172
3191
  * ```ts
3173
- * onAuthStateChange((event, 받은것) => {
3192
+ * onAuthStateChange((event, incoming) => {
3174
3193
  * getSession().then(({ data }) => {
3175
- * const 이탭것 = data.session
3176
- * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
3177
- * …이탭것 으로 상태를 맞춘다
3194
+ * const thisTab = data.session
3195
+ * if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
3196
+ * …thisTab 으로 상태를 맞춘다
3178
3197
  * })
3179
3198
  * })
3180
3199
  * ```
3181
3200
  *
3182
- * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
3183
- * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
3201
+ * @param incoming 이벤트가 실어 온 세션(없을 수 있다)
3202
+ * @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
3184
3203
  * @public
3185
3204
  */
3186
- export declare function isSessionForThisTab(받은것: TabSessionLike | null | undefined, 이탭것: TabSessionLike | null | undefined): boolean;
3205
+ export declare function isSessionForThisTab(incoming: TabSessionLike | null | undefined, thisTab: TabSessionLike | null | undefined): boolean;
3187
3206
 
3188
3207
  /**
3189
3208
  * @public 문자열이 음절 패턴 DSL 문법인지 — 쉼표(,) 포함 여부로 판별한다.
@@ -3982,7 +4001,7 @@ declare interface NoiseContext_2 {
3982
4001
  * @public 소음 `<audio>` 볼륨(0~1) = min(1, 들리는 크기 ÷ gain). gain 은 소음 파일이 원본보다 몇 배
3983
4002
  * 큰지 — 기본은 `configureStorage({ noiseFileGain })`(없으면 1).
3984
4003
  */
3985
- export declare function noiseElementVolume(settings: 비율설정, gain?: number): number;
4004
+ export declare function noiseElementVolume(settings: SpeechVolumeSettings, gain?: number): number;
3986
4005
 
3987
4006
  /** @public */
3988
4007
  export declare const NoisePlayerContext: Context<NoisePlayerContextValue | null>;
@@ -4040,13 +4059,28 @@ export declare interface NoiseSettingsStore {
4040
4059
  * 설정값은 NoisePlayerContext(useNoisePlayer)로 읽고 쓴다.
4041
4060
  * @public
4042
4061
  */
4043
- export declare function NoiseSettingsTooltip({ anchorRect, onClose }: NoiseSettingsTooltipProps): JSX.Element;
4062
+ export declare function NoiseSettingsTooltip({ anchorRect, onClose, hideNoiseRows }: NoiseSettingsTooltipProps): JSX.Element;
4044
4063
 
4045
4064
  /** @public */
4046
4065
  export declare interface NoiseSettingsTooltipProps {
4047
4066
  /** 앵커(소음 설정 버튼) 위치. */
4048
4067
  anchorRect: DOMRect | null;
4049
4068
  onClose: () => void;
4069
+ /**
4070
+ * **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
4071
+ *
4072
+ * 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
4073
+ * `전체 소리`·`재생 속도` 는 그대로 선다.
4074
+ *
4075
+ * **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
4076
+ * *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
4077
+ * (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
4078
+ *
4079
+ * **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
4080
+ * 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
4081
+ * 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
4082
+ */
4083
+ hideNoiseRows?: boolean;
4050
4084
  }
4051
4085
 
4052
4086
  /**
@@ -4355,10 +4389,8 @@ declare type PanelRowDescription = {
4355
4389
  export declare function PanelRowDevice({ devices, onRelease, emptyLabel }: PanelRowDeviceProps): JSX.Element;
4356
4390
 
4357
4391
  export declare interface PanelRowDeviceProps {
4358
- devices: {
4359
- 좌: DeviceEntry | null;
4360
- 우: DeviceEntry | null;
4361
- };
4392
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` 값이다(`HearingDeviceInfo` 와 같은 꼴) */
4393
+ devices: Record<DeviceSide, DeviceEntry | null>;
4362
4394
  /** 있으면 각 쪽 이름 옆에 '착용 해제'가 선다 — 그 쪽의 '지금 착용중' 만 내린다(이력은 남는다) */
4363
4395
  onRelease?: (side: DeviceSide) => void;
4364
4396
  /**
@@ -5263,7 +5295,7 @@ export declare const POS_LABEL: Record<string, string>;
5263
5295
  * 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
5264
5296
  * @public
5265
5297
  */
5266
- export declare function posLabels(t: 번역): Record<string, string>;
5298
+ export declare function posLabels(t: Translate): Record<string, string>;
5267
5299
 
5268
5300
  /**
5269
5301
  * **늦게 받는 화면 청크를 한가할 때 미리 받아 둔다.**
@@ -6730,7 +6762,7 @@ export declare type ShuffledItem = {
6730
6762
  text: string;
6731
6763
  };
6732
6764
 
6733
- declare type Side = '좌' | '우';
6765
+ declare type Side = 'left' | 'right';
6734
6766
 
6735
6767
  /** @public */
6736
6768
  export declare function SideNav(props: SideNavProps): React_2.JSX.Element;
@@ -7084,6 +7116,13 @@ export declare interface SpeakerPos {
7084
7116
  /** @public */
7085
7117
  export declare type SpeakerState = 'idle' | 'active' | 'played' | 'correct' | 'wrong';
7086
7118
 
7119
+ /** `effectiveSpeechVolume`·`effectiveNoiseVolume`·`noiseElementVolume` 이 받는 것 —
7120
+ * **공개 `d.ts` 에 이름이 그대로 나가는 자리**라 영어로 둔다. 안쪽 이름은 한글로 쓰는 저장소지만,
7121
+ * 소비자가 편집기에서 보는 이름은 영어라야 한다(사용자 지시 2026-10-13). */
7122
+ declare type SpeechVolumeSettings = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
7123
+ noiseRatio?: number;
7124
+ };
7125
+
7087
7126
  /**
7088
7127
  * 앱에 처음 들어올 때 로고를 한 번 보여 주는 연출.
7089
7128
  *
@@ -8501,7 +8540,7 @@ export declare interface TrainingBlockProps {
8501
8540
  export declare function TrainingCard({ title, titleLevel, description, topActions, children, footerLeft, footerRight, ariaLabel, onClick, draggable, onDragStart, onDragEnd, className, }: TrainingCardProps): JSX.Element;
8502
8541
 
8503
8542
  /** @public */
8504
- export declare function TrainingCardGroup({ title, titleLevel, count, collapsed, onToggle, sort, actions, empty, emptyContent, dragOver, onDragOver, onDragLeave, onDrop, children, className, }: TrainingCardGroupProps): JSX.Element;
8543
+ export declare function TrainingCardGroup({ title, titleLevel, count, collapsed, onToggle, beforeSort, sort, actions, empty, emptyContent, dragOver, onDragOver, onDragLeave, onDrop, children, className, }: TrainingCardGroupProps): JSX.Element;
8505
8544
 
8506
8545
  /** @public */
8507
8546
  declare type TrainingCardGroupProps = {
@@ -8535,6 +8574,25 @@ declare type TrainingCardGroupProps = {
8535
8574
  * "접기, 단추" 로 읽혔다. 소비자 아홉 자리 중 여덟이 그랬다.)
8536
8575
  */
8537
8576
  onToggle?: () => void;
8577
+ /**
8578
+ * **정렬 드롭다운 바로 왼쪽 자리**((209), 사장님 지시 2026-10-12 — 노션 QA *"단어표, 대립쌍표의
8579
+ * 그룹 순서 변경 기능 필요"*). 그룹 **차례를 바꾸는 손잡이**가 설 자리다.
8580
+ *
8581
+ * 사장님이 길 둘을 말씀하셨다 — *"그룹 자체를 드래그 하거나 정렬 왼쪽에 올림 내림 버튼을
8582
+ * 넣어서"*. **둘 다 이 한 자리에 든다**(끄는 손잡이든 ↑↓ 단추든). 그래서 코어는 자리만 열고
8583
+ * **무엇을 세울지는 부르는 쪽이 고른다** — 차례를 알고 간수하는 것이 그쪽이다.
8584
+ *
8585
+ * `actions` 와 **같은 대접**이다: 같은 `__actions` 감싸개 안이라 누름이 **접기 토글로 안
8586
+ * 번진다.** 밖에 두면 ↑ 를 누를 때마다 그 그룹이 접힌다.
8587
+ *
8588
+ * **낭독기 이름은 부르는 쪽이 단다** — `접기`·`정렬` 은 이 조각이 묶음 이름을 달지만(위
8589
+ * `titleLevel` 글), 넘겨받은 마디의 속을 이 조각이 고쳐 쓸 수는 없다. `actions` 와 같은
8590
+ * 규칙이다: **`위로` 가 그룹 수만큼 서면 단추 목록에서 못 가린다** — `위로` 가 아니라
8591
+ * `'sdfa' 그룹 위로` 꼴로 지어 넘긴다.
8592
+ *
8593
+ * 안 주면 자리를 안 만든다(지금과 같다).
8594
+ */
8595
+ beforeSort?: ReactNode;
8538
8596
  /** 헤더 우측, actions 앞에 놓이는 그룹 내 정렬 드롭다운 */
8539
8597
  sort?: TrainingCardGroupSort;
8540
8598
  /** 헤더 우측 액션 영역 (이름변경·추가·삭제 등) — 클릭이 토글로 전파되지 않음 */
@@ -8823,7 +8881,7 @@ export declare interface TrainingItemResult {
8823
8881
  export declare const TrainingPagePopup: TrainingPagePopupComponent;
8824
8882
 
8825
8883
  /** @public */
8826
- export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): JSX.Element;
8884
+ export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, hideNoiseRows, }: TrainingPagePopupBottomProps): JSX.Element;
8827
8885
 
8828
8886
  /** @public */
8829
8887
  export declare type TrainingPagePopupBottomProps = {
@@ -8838,6 +8896,21 @@ export declare type TrainingPagePopupBottomProps = {
8838
8896
  pageIndex?: number;
8839
8897
  /** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
8840
8898
  pageCount?: number;
8899
+ /**
8900
+ * **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
8901
+ *
8902
+ * 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
8903
+ * `전체 소리`·`재생 속도` 는 그대로 선다.
8904
+ *
8905
+ * **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
8906
+ * *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
8907
+ * (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
8908
+ *
8909
+ * **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
8910
+ * 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
8911
+ * 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
8912
+ */
8913
+ hideNoiseRows?: boolean;
8841
8914
  };
8842
8915
 
8843
8916
  /** @public */
@@ -8922,6 +8995,21 @@ export declare type TrainingPagePopupProps = {
8922
8995
  * 판에 실제로 걸린 값은 `NoiseSettingsStore.applied` 로 받는다.
8923
8996
  */
8924
8997
  noiseOverride?: Partial<CoreNoiseSettings> | ((saved: CoreNoiseSettings) => Partial<CoreNoiseSettings>);
8998
+ /**
8999
+ * **소음 두 줄을 안 그린다** — `소음 종류`·`소음 비율`(사장님 지시 (207), 2026-10-12).
9000
+ *
9001
+ * 소음이 아예 없는 훈련(짧은 이야기 듣기 · 긴 이야기 듣기 · 문장 순서화)에서 쓴다.
9002
+ * `전체 소리`·`재생 속도` 는 그대로 선다.
9003
+ *
9004
+ * **흐리게가 아니라 안 그린다.** 흐린 줄은 *"지금은 못 바꾼다"* 를 말하는데, 여기서 할 말은
9005
+ * *"이 훈련에는 소음이 없다"* 다. 흐린 채로 두면 치료사가 **바꿀 수 있는 줄을 찾아 헤맨다**
9006
+ * (사장님이 그 줄을 짚으며 *"왜 이거 수정요청 했는데 계속 나오고 있지?"* 라 하셨다).
9007
+ *
9008
+ * **`noiseOverride` 와 따로 둔다 — 덮개는 *값*, 이 칸은 *보임*.** 묶으면 *"덮개는 걸되 줄은
9009
+ * 보이게"* 를 못 한다 — 소음 하 문장 듣기의 제안 갈래가 그 꼴이다(덮개를 걸고도 치료사가
9010
+ * 창에서 고친다). **한 칸이 두 가지를 뜻하게 하지 않는다.**
9011
+ */
9012
+ hideNoiseRows?: boolean;
8925
9013
  /** 훈련 결과 팝업 렌더러 — 없으면 기본 결과 팝업(TrainingResultPopup)을 띄운다 */
8926
9014
  renderResultPopup?: (props: {
8927
9015
  score?: number | null;
@@ -9205,7 +9293,9 @@ export declare type TrainingStepConfirmProps = {
9205
9293
  * 그 판이 통째로 사라졌다(말귀 실측: 「3대 영양소」 보기 여섯·정답 넷, 셋을 골라도 아무
9206
9294
  * 일도 안 남). **기억을 묻는 문제에서 덜 기억한 답도 그 사람의 답이다.**
9207
9295
  *
9208
- * 이제 여럿 고르기는 **`확인` 단추**로 낸다. 이 값은 채점 쪽이 쓰라고 그대로 받아 둔다.
9296
+ * 내는 자리는 **`결과보기`** 다 — (102) 때 세운 `확인` 단추는 (206) 으로 걷혔다. 고르는
9297
+ * 그 자리에서 매번 보고하되 **개수로는 넘어가지 않으므로** 위 두 까닭이 그대로 선다.
9298
+ * 이 값은 채점 쪽이 쓰라고 그대로 받아 둔다 — **이 조각은 이 수로 아무 가름도 하지 않는다.**
9209
9299
  */
9210
9300
  correctCount?: number;
9211
9301
  useDisabledCovered?: boolean;
@@ -9450,6 +9540,13 @@ export declare interface TrainingWordCardProps {
9450
9540
  orderRank?: number | null;
9451
9541
  }
9452
9542
 
9543
+ /** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
9544
+ /** i18next `t` 와 같은 꼴 — **공개 서명(`posLabels`·`wordTypeLabels`)이 받는 자리**라 이름을 영어로 둔다
9545
+ * (사용자 지시 2026-10-13). 안쪽 이름은 한글이어도 소비자가 보는 이름은 영어라야 한다. */
9546
+ declare type Translate = (k: string, o: {
9547
+ defaultValue: string;
9548
+ }) => string;
9549
+
9453
9550
  declare type TranslateFn = TFunction;
9454
9551
 
9455
9552
  /**
@@ -9695,22 +9792,10 @@ export declare function useDeviceManagement(): {
9695
9792
  setShowDevice: Dispatch<SetStateAction<boolean>>;
9696
9793
  deviceDirection: Side;
9697
9794
  setDeviceDirection: Dispatch<SetStateAction<Side>>;
9698
- devices: {
9699
- 좌: DeviceForm | null;
9700
- 우: DeviceForm | null;
9701
- };
9702
- setDevices: Dispatch<SetStateAction< {
9703
- 좌: DeviceForm | null;
9704
- 우: DeviceForm | null;
9705
- }>>;
9706
- deviceHistory: {
9707
- 좌: DeviceForm[];
9708
- 우: DeviceForm[];
9709
- };
9710
- setDeviceHistory: Dispatch<SetStateAction< {
9711
- 좌: DeviceForm[];
9712
- 우: DeviceForm[];
9713
- }>>;
9795
+ devices: Record<Side, DeviceForm | null>;
9796
+ setDevices: Dispatch<SetStateAction<Record<Side, DeviceForm | null>>>;
9797
+ deviceHistory: Record<Side, DeviceForm[]>;
9798
+ setDeviceHistory: Dispatch<SetStateAction<Record<Side, DeviceForm[]>>>;
9714
9799
  showDeviceList: boolean;
9715
9800
  setShowDeviceList: Dispatch<SetStateAction<boolean>>;
9716
9801
  listForSide: Side;
@@ -9920,15 +10005,15 @@ export declare function usePagedList<T>(items: T[], perPage: number, page: numbe
9920
10005
  * React 가 이 꼴을 위해 내준 길이다(같은 컴포넌트를 바로 다시 그린다).
9921
10006
  *
9922
10007
  * ```ts
9923
- * usePageResetOnChange(`${검색어}|${갈래}|${정렬}`, setPage)
10008
+ * usePageResetOnChange(`${검색어}|${갈래}|${sort}`, setPage)
9924
10009
  * const { pageItems, totalPages } = usePagedList(걸러낸것, 쪽크기, page)
9925
10010
  * ```
9926
10011
  *
9927
- * @param 열쇠 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
10012
+ * @param key 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
9928
10013
  * @param setPage 쪽 번호를 쥔 쪽에서 준다
9929
10014
  * @public
9930
10015
  */
9931
- export declare function usePageResetOnChange(열쇠: string, setPage: (n: number) => void): void;
10016
+ export declare function usePageResetOnChange(key: string, setPage: (n: number) => void): void;
9932
10017
 
9933
10018
  /**
9934
10019
  * 비밀번호 변경 폼 상태와 핸들러를 캡슐화한 훅.
@@ -10033,28 +10118,39 @@ export declare function useTablePaging<T>(rows: readonly T[], resetOn?: unknown,
10033
10118
  * **정렬이 바뀌면 첫 쪽으로** — `useTablePaging` 의 `resetOn` 에 돌려받은 `정렬` 을 그대로 넣는다.
10034
10119
  * `정렬` 은 누를 때만 새 객체가 되므로(렌더마다 새로 만들지 않는다) 그 자체가 열쇠다. 쪽을 따로 들고 있으면 `onSortChange` 에서 되돌린다.
10035
10120
  *
10036
- * `sortRows(rows, 값)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `칸순`(숫자는 수로,
10121
+ * `sortRows(rows, getValue)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `compareCell`(숫자는 수로,
10037
10122
  * 한글·영문은 사람 차례) 으로 견주고 **빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤**(ListPopup 과 같은 규칙).
10038
10123
  *
10039
10124
  * @example
10040
- * const { 정렬, 칸, sortRows } = useTableSort<'name' | 'joined'>()
10125
+ * const { sort, field, sortRows } = useTableSort<'name' | 'joined'>()
10041
10126
  * const 세운줄 = useMemo(() => sortRows(rows, (r, key) => r[key]), [rows, sortRows])
10042
- * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, 정렬) // 정렬이 바뀌면 첫 쪽
10043
- * <Table fields={[칸('이름', 'name'), 칸('가입일', 'joined')]} … />
10127
+ * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, sort) // 정렬이 바뀌면 첫 쪽
10128
+ * <Table fields={[field('이름', 'name'), field('가입일', 'joined')]} … />
10044
10129
  *
10045
10130
  * @public
10046
10131
  */
10047
10132
  export declare function useTableSort<K extends string>(opts?: TableSortOptions<K>): {
10133
+ sort: {
10134
+ key: K;
10135
+ asc: boolean;
10136
+ } | null;
10137
+ field: (label: string, key: K) => {
10138
+ label: string;
10139
+ sortDir: "desc" | "asc" | null;
10140
+ onClick: () => void;
10141
+ };
10142
+ sortRows: <R>(rows: readonly R[], getValue: (row: R, key: K) => unknown) => readonly R[];
10143
+ /** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
10048
10144
  정렬: {
10049
10145
  key: K;
10050
10146
  asc: boolean;
10051
10147
  } | null;
10148
+ /** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
10052
10149
  칸: (label: string, key: K) => {
10053
10150
  label: string;
10054
10151
  sortDir: "desc" | "asc" | null;
10055
10152
  onClick: () => void;
10056
10153
  };
10057
- sortRows: <R>(rows: readonly R[], 값: (row: R, key: K) => unknown) => readonly R[];
10058
10154
  };
10059
10155
 
10060
10156
  /**
@@ -10321,12 +10417,6 @@ export declare function useWordTrainingDataGeneration(types: WordTrainingType[],
10321
10417
  generating: boolean;
10322
10418
  };
10323
10419
 
10324
- /**
10325
- * 단어 훈련 그룹별 필터 상태.
10326
- * @param groupKey 그룹 식별자 (localStorage 키 및 상태 격리에 사용)
10327
- * @param caps 그룹이 노출할 설정 (난이도/단어구성). 제외 필터는 항상 노출.
10328
- * @public
10329
- */
10330
10420
  export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
10331
10421
  resetOnMount?: boolean;
10332
10422
  resetKey?: string;
@@ -11532,7 +11622,7 @@ export declare type WordTrainingTypeKey = 'SAME_SOUND_PICK' | 'SAME_OR_DIFF' | '
11532
11622
  * **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
11533
11623
  * @public
11534
11624
  */
11535
- export declare function wordTypeLabels(t: 번역): Record<string, string>;
11625
+ export declare function wordTypeLabels(t: Translate): Record<string, string>;
11536
11626
 
11537
11627
  /**
11538
11628
  * @public 틀린 문항만 — 음소별 오류를 세는 쪽이 먼저 볼 것이다.
@@ -11541,13 +11631,4 @@ export declare function wordTypeLabels(t: 번역): Record<string, string>;
11541
11631
  */
11542
11632
  export declare function wrongItems(items: readonly TrainingItemResult[]): TrainingItemResult[];
11543
11633
 
11544
- /** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
11545
- declare type 번역 = (k: string, o: {
11546
- defaultValue: string;
11547
- }) => string;
11548
-
11549
- declare type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
11550
- noiseRatio?: number;
11551
- };
11552
-
11553
11634
  export { }
@@ -1,5 +1,5 @@
1
1
  import './HearingDeviceInfo.css';
2
- export type DeviceSide = '좌' | '우';
2
+ export type DeviceSide = 'left' | 'right';
3
3
  export interface DeviceEntry {
4
4
  brand: string;
5
5
  device: string;
@@ -10,10 +10,10 @@ export interface DeviceEntry {
10
10
  wornDay: string;
11
11
  }
12
12
  interface HearingDeviceInfoProps {
13
- devices: {
14
- 좌: DeviceEntry | null;
15
- 우: DeviceEntry | null;
16
- };
13
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` **값**(`'left'`·`'right'`)이다.
14
+ * 그 값은 서버의 `side` 열과 그대로 오가는 **자료**라 건드리지 않았고, 공개 자리에 보이는
15
+ * **모양만** 영어 이름으로 돌렸다(`Record`). 값까지 바꾸는 것은 DB 결정이다(사용자 지시 2026-10-13). */
16
+ devices: Record<DeviceSide, DeviceEntry | null>;
17
17
  onAddDevice: (side: DeviceSide) => void;
18
18
  onOpenList: (side: DeviceSide) => void;
19
19
  onRemoveDevice: (side: DeviceSide) => void;
@@ -6,9 +6,9 @@ import { formatWornDate } from '../utils/wornDate';
6
6
  import './HearingDeviceInfo.css';
7
7
  export default function HearingDeviceInfo({ devices, onAddDevice, onOpenList, onRemoveDevice }) {
8
8
  const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
9
- return (_jsxs("div", { className: "hearing-device-info", children: [_jsx("div", { className: "hearing-device-info__title body-3", children: t('customers.device.title', '청각재활기기') }), _jsx("div", { className: "hearing-device-info__row", children: ['좌', '우'].map(side => {
9
+ return (_jsxs("div", { className: "hearing-device-info", children: [_jsx("div", { className: "hearing-device-info__title body-3", children: t('customers.device.title', '청각재활기기') }), _jsx("div", { className: "hearing-device-info__row", children: ['left', 'right'].map(side => {
10
10
  const d = devices[side];
11
- const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
11
+ const sideLabel = side === 'left' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
12
12
  return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) && _jsx("span", { className: "hearing-device-info__device-date", children: formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
13
13
  }) })] }));
14
14
  }
@@ -153,10 +153,8 @@ export type PanelRowProps = PanelRowToggle | PanelRowSegmented | PanelRowDropdow
153
153
  export declare function PanelRow(props: PanelRowProps): import("react").JSX.Element;
154
154
  export type { DeviceSide };
155
155
  export interface PanelRowDeviceProps {
156
- devices: {
157
- 좌: DeviceEntry | null;
158
- 우: DeviceEntry | null;
159
- };
156
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` 값이다(`HearingDeviceInfo` 와 같은 꼴) */
157
+ devices: Record<DeviceSide, DeviceEntry | null>;
160
158
  /** 있으면 각 쪽 이름 옆에 '착용 해제'가 선다 — 그 쪽의 '지금 착용중' 만 내린다(이력은 남는다) */
161
159
  onRelease?: (side: DeviceSide) => void;
162
160
  /**
@@ -152,10 +152,10 @@ function 메타(d) {
152
152
  }
153
153
  export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
154
154
  const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
155
- const SIDES = ['좌', '우'];
155
+ const SIDES = ['left', 'right'];
156
156
  return (_jsx("div", { className: "panel-row panel-row--device", children: SIDES.map(side => {
157
157
  const d = devices[side];
158
- const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
158
+ const sideLabel = side === 'left' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
159
159
  return (_jsxs("div", { className: "panel-row__device-column", children: [_jsxs("div", { className: "panel-row__device-head", children: [_jsx("span", { className: "panel-row__device-side body-2b", children: sideLabel }), d && onRelease && (
160
160
  /* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
161
161
  이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
@@ -3,14 +3,10 @@ import type { DeviceSide } from '../Panel';
3
3
  import type { DeviceForm } from '../../types/device';
4
4
  import './DeviceListPopup.css';
5
5
  interface DeviceListPopupProps {
6
- devices: {
7
- 좌: DeviceEntry | null;
8
- 우: DeviceEntry | null;
9
- };
10
- deviceHistory: {
11
- 좌: DeviceEntry[];
12
- 우: DeviceEntry[];
13
- };
6
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` 값이다 */
7
+ devices: Record<DeviceSide, DeviceEntry | null>;
8
+ /** 쪽별로 바꿔 온 이력 */
9
+ deviceHistory: Record<DeviceSide, DeviceEntry[]>;
14
10
  onClose: () => void;
15
11
  onSelect: (d: DeviceForm) => void;
16
12
  onDelete: (side: DeviceSide, index: number) => void;
@@ -16,7 +16,7 @@ import './DeviceListPopup.css';
16
16
  */
17
17
  export default function DeviceListPopup({ devices, deviceHistory, onClose, onSelect, onDelete }) {
18
18
  const { t } = useTranslation();
19
- const rows = useMemo(() => ['좌', '우'].flatMap(side => deviceHistory[side].map((d, index) => ({
19
+ const rows = useMemo(() => ['left', 'right'].flatMap(side => deviceHistory[side].map((d, index) => ({
20
20
  index, side,
21
21
  brand: d.brand, device: d.device,
22
22
  fitType: d.fitType ?? '', productName: d.productName ?? '',
@@ -17,7 +17,7 @@ function 기기종류이름(device, t) {
17
17
  return k ? t(`device.type.${k}`, { defaultValue: device }) : device;
18
18
  }
19
19
  import { useTablePaging, useTableSort } from '../../hooks/useTablePaging';
20
- import { 칸순빈값뒤 } from '../../utils/textSort';
20
+ import { compareCellBlankLast } from '../../utils/textSort';
21
21
  import './DeviceListPopup.css';
22
22
  /**
23
23
  * 정렬에 쓸 문자열. 날짜는 **자리수를 채워** 잰다 — '2026.5.2' 와 '2026.11.5' 를
@@ -31,7 +31,7 @@ function 정렬값(r, key) {
31
31
  }
32
32
  export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }) {
33
33
  const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
34
- const 방향이름 = (side) => side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
34
+ const 방향이름 = (side) => side === 'left' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
35
35
  /* 머리행을 누르면 그 칸으로 정렬한다 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
36
36
  쪽넘김보다 **앞에서** 정렬한다(훅에 이미 정렬된 줄을 건넨다). */
37
37
  const { 정렬, 칸 } = useTableSort();
@@ -44,7 +44,7 @@ export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onW
44
44
  const cmp = Number(b.worn) - Number(a.worn); // 켜진 것이 먼저
45
45
  return asc ? cmp : -cmp;
46
46
  }
47
- return 칸순빈값뒤(정렬값(a, key), 정렬값(b, key), asc); // 빈 칸(착용일 없음 등)은 오름·내림 모두 맨 뒤 — useTableSort 와 같은 규칙
47
+ return compareCellBlankLast(정렬값(a, key), 정렬값(b, key), asc); // 빈 칸(착용일 없음 등)은 오름·내림 모두 맨 뒤 — useTableSort 와 같은 규칙
48
48
  });
49
49
  }, [rows, 정렬]);
50
50
  /* 개수 보기·쪽넘김은 방문 표와 같은 것을 쓴다 — 같은 자리에서 같은 뜻으로 읽히게 */
@@ -6,7 +6,7 @@ import Popup from './Popup';
6
6
  import Table from '../Table';
7
7
  import Toolbar from '../Toolbar';
8
8
  import './ListPopup.css';
9
- import { 칸순빈값뒤 } from '../../utils/textSort';
9
+ import { compareCellBlankLast } from '../../utils/textSort';
10
10
  import { cx } from '../../utils/cx';
11
11
  /** @public */
12
12
  export default function ListPopup(props) {
@@ -48,9 +48,9 @@ export default function ListPopup(props) {
48
48
  return 0;
49
49
  /* `<`·`>` 로 견주면 코드포인트 차례가 된다 — 한글끼리는 맞아떨어져 안 드러나다가
50
50
  **한글과 영문이 섞이면** 어긋난다(라틴이 한글보다 앞이라 `fwef` 가
51
- `검수테스트2` 위에 선다). 숫자 칸은 숫자로 견주는 것을 `칸순` 이 지킨다.
51
+ `검수테스트2` 위에 선다). 숫자 칸은 숫자로 견주는 것을 `compareCell` 이 지킨다.
52
52
  빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤 — 전에는 '' 로 채워 오름차순 맨 앞에 섰다(useTableSort 와 같은 규칙). */
53
- return 칸순빈값뒤(va, vb, sortAsc);
53
+ return compareCellBlankLast(va, vb, sortAsc);
54
54
  });
55
55
  }, [items, search, sortKey, sortAsc, searchEnabled, effectiveSearchFn]);
56
56
  const totalPages = Math.max(1, Math.ceil(filtered.length / Number(pageSize)));
@@ -6,7 +6,7 @@ import Popup from './Popup';
6
6
  import Button from '../Button';
7
7
  import Dropdown from '../Dropdown';
8
8
  import { getSavedTables, deleteTable as localDeleteTable } from '../../utils/savedWordTables';
9
- import { 한글순 } from '../../utils/textSort';
9
+ import { compareKorean } from '../../utils/textSort';
10
10
  import { formatYmd } from '../../utils/date';
11
11
  import './LoadTablePopup.css';
12
12
  import { pressable } from '../../utils/pressable';
@@ -59,9 +59,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
59
59
  return copy.sort((a, b) => String(b.savedAt ?? '').localeCompare(String(a.savedAt ?? '')));
60
60
  if (sortKey === 'oldest')
61
61
  return copy.sort((a, b) => String(a.savedAt ?? '').localeCompare(String(b.savedAt ?? '')));
62
- /* 이름은 **`한글순`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
62
+ /* 이름은 **`compareKorean`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
63
63
  if (sortKey === 'name')
64
- return copy.sort((a, b) => 한글순(a.name, b.name));
64
+ return copy.sort((a, b) => compareKorean(a.name, b.name));
65
65
  if (sortKey === 'count')
66
66
  return copy.sort((a, b) => b.items.length - a.items.length);
67
67
  return copy;
@@ -19,21 +19,24 @@ export declare const NOISE_RATIO_STEP = 0.1;
19
19
  export declare function noiseRatioOf(settings: Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
20
20
  noiseRatio?: number;
21
21
  }): number;
22
- type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
22
+ /** `effectiveSpeechVolume`·`effectiveNoiseVolume`·`noiseElementVolume` 이 받는 것 —
23
+ * **공개 `d.ts` 에 이름이 그대로 나가는 자리**라 영어로 둔다. 안쪽 이름은 한글로 쓰는 저장소지만,
24
+ * 소비자가 편집기에서 보는 이름은 영어라야 한다(사용자 지시 2026-10-13). */
25
+ type SpeechVolumeSettings = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
23
26
  noiseRatio?: number;
24
27
  };
25
28
  /** @public 문제 소리 볼륨(0~1) = mainVolume. 비율과 상관없다. */
26
- export declare function effectiveSpeechVolume(settings: 비율설정): number;
29
+ export declare function effectiveSpeechVolume(settings: SpeechVolumeSettings): number;
27
30
  /**
28
31
  * @public 소음이 **들리는 크기**(0~2, 원본 소음 파일 기준) = mainVolume × 비율. 켜짐 여부는 보지
29
32
  * 않는다. 결과 기록·파형이 쓴다. 소음 `<audio>` 에 거는 값은 `noiseElementVolume`.
30
33
  */
31
- export declare function effectiveNoiseVolume(settings: 비율설정): number;
34
+ export declare function effectiveNoiseVolume(settings: SpeechVolumeSettings): number;
32
35
  /**
33
36
  * @public 소음 `<audio>` 볼륨(0~1) = min(1, 들리는 크기 ÷ gain). gain 은 소음 파일이 원본보다 몇 배
34
37
  * 큰지 — 기본은 `configureStorage({ noiseFileGain })`(없으면 1).
35
38
  */
36
- export declare function noiseElementVolume(settings: 비율설정, gain?: number): number;
39
+ export declare function noiseElementVolume(settings: SpeechVolumeSettings, gain?: number): number;
37
40
  /**
38
41
  * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
39
42
  *
@@ -62,3 +62,22 @@
62
62
  .noise-settings__value--disabled {
63
63
  opacity: 0.4;
64
64
  }
65
+
66
+ /* (208) 폭만 빌리는 그림자 줄 — NoiseSettingsTooltip.tsx 의 `noise-settings__width-ghost` 주석을 볼 것.
67
+ 가로 폭은 참 줄과 똑같이 내고(그래서 좌우 패딩은 그대로), 높이만 0 으로 접는다.
68
+ `visibility: hidden` 이라 선도 그림자도 안 그린다. 줄 사이 구분선은 `.panel-row + .panel-row` 인데
69
+ 이 감싸개는 `.panel-row` 가 아니라 그 규칙에 안 걸린다 — 그래도 안쪽 줄의 선·여백·최소높이를 0 으로 둔다. */
70
+ .noise-settings__width-ghost {
71
+ visibility: hidden;
72
+ height: 0;
73
+ overflow: hidden;
74
+ pointer-events: none;
75
+ }
76
+ .noise-settings__width-ghost .panel-row {
77
+ padding-top: 0;
78
+ padding-bottom: 0;
79
+ margin: 0;
80
+ border: 0;
81
+ min-height: 0;
82
+ }
83
+