@ihabdevteam/core 0.345.0 → 0.346.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.
@@ -1754,7 +1754,7 @@ export declare function copyText(text: string): Promise<boolean>;
1754
1754
  *
1755
1755
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1756
1756
  */
1757
- export declare const CORE_VERSION = "0.345.0";
1757
+ export declare const CORE_VERSION = "0.346.0";
1758
1758
 
1759
1759
  /** @public */
1760
1760
  declare const coreDefault: Record<string, unknown>;
@@ -11126,14 +11126,6 @@ export declare interface WordSearchResultGroup {
11126
11126
  export declare interface WordSearchResultItem {
11127
11127
  id: string;
11128
11128
  words: WordSearchResultWord[];
11129
- /**
11130
- * 갈린 자리 — **옛 한글 이름**(`'초성'`·`'모음'`·`'종성'`·`'활음'`)이다.
11131
- *
11132
- * @deprecated `diffSectionCode` 를 쓴다 — 같은 자리를 코드로 가리킨다. 2026-10-13 안쪽을
11133
- * 코드로 옮겼지만 **소비자가 이 값으로 묶음을 가른다**(말귀 `WordPairsPage` 의 `GROUP_ORDER`).
11134
- * 혼자 바꾸면 그쪽 묶음이 조용히 빈다. 소비자가 옮긴 뒤 걷는다.
11135
- */
11136
- diffSection?: string;
11137
11129
  /** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
11138
11130
  diffSectionCode?: PhonemeSectionCode;
11139
11131
  diffHasEmpty?: boolean;
@@ -248,11 +248,22 @@
248
248
  box-shadow: 0 0 6px rgba(0,0,0,0.4);
249
249
  }
250
250
 
251
- /* Show spinner only during loading — hide the textual label.
252
- !important 불필요 — .audio-player__loading-text의 display를 지정하는 규칙이 이것뿐이라 경합 대상이 없음. */
251
+ /* **눈에서 감추되 낭독기에는 남긴다**(사장님 결정 (다), 2026-10-19 — (305)).
252
+ 전에는 `display: none` 이었다. 글자는 안 보였지만 **접근성 나무에서도 빠져** 기다리는
253
+ 동안 아무 말도 안 읽혔다 — 위의 `role="status"`·`aria-live` 가 헛돌았다.
254
+ 저장소 처음부터 그랬다(`speechear 복사본`).
255
+
256
+ 이 저장소가 쓰는 감추는 규약(`Table.css`·`Board.css`·`WordTable.css` 의 `clip-path: inset(50%)`)
257
+ 을 그대로 쓴다 — 자리를 안 차지하고 보이지 않지만 **읽힌다.**
258
+ `display: none` 이나 `visibility: hidden` 으로 바꾸면 읽히는 것이 다시 사라진다. */
253
259
  .audio-player__loading .audio-player__loading-text,
254
260
  .audio-player__panel.is-loading .audio-player__loading-text {
255
- display: none;
261
+ position: absolute;
262
+ width: 1px;
263
+ height: 1px;
264
+ overflow: hidden;
265
+ clip-path: inset(50%);
266
+ white-space: nowrap;
256
267
  }
257
268
 
258
269
  .audio-player__loading-spinner {
@@ -18,7 +18,9 @@ import Toast from '../Toast';
18
18
  import Button from '../Button';
19
19
  import Icon from '../Icon';
20
20
  import InfoTooltip from '../Tooltip/InfoTooltip';
21
- import { STATUS_MESSAGES, ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
21
+ /* `STATUS_MESSAGES` 를 더는 안 들여온다 — 걸음마다 다른 글을 화면에 쓰지 않기 때문이다
22
+ (사장님 결정 (다), 2026-10-19). 그 표는 **지우지 않았다**: 개발 판의 자취와 색이 쓴다. */
23
+ import { ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
22
24
  import { wasKeyboardPress } from '../../hooks/inputModality';
23
25
  import { measureAudioDuration, waitForCanPlay } from './audioWaits';
24
26
  import { useWaveReady } from './useWaveReady';
@@ -1418,10 +1420,20 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1418
1420
  const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
1419
1421
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1420
1422
  return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (
1421
- /* **알릴 값이 있을 때만 읽히게 한다**(사장님 결정 (나), 2026-10-05).
1422
- `준비 완료` 는 일이 끝난 뒤의 알림이라, 소리 한 번에 말 한 마디가 거저 얹혔다.
1423
- 보이는 글은 그대로다 — 읽히는 것만 준다(`ANNOUNCED_STATUSES`). */
1424
- _jsxs("div", { className: "audio-player__loading", role: ANNOUNCED_STATUSES[fetchDebugStatus] ? 'status' : undefined, "aria-live": ANNOUNCED_STATUSES[fetchDebugStatus] ? 'polite' : 'off', children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
1423
+ /* **기다리는 동안 글자를 안 띄운다**(사장님 결정 (다), 2026-10-19 — (305)).
1424
+ 전에는 걸음마다 글이 바뀌었다: `소리를 만들고 있습니다…` 가 **2ms 뒤에**
1425
+ `리소스 매핑 중…` 으로 덮이고 `서명 URL 조회…` · `다운로드 중…` 까지 늘어섰다
1426
+ (말귀 실측 2026-10-19: 캐시가 다 찬 가장 짧은 판에서도 94ms · 746ms).
1427
+ 사장님 규칙은 **개발자의 말을 화면에 쓰지 않는다**다.
1428
+
1429
+ 골라 둔 길 (가)(사람 말 한 마디로 묶기)가 아니라 **글을 아예 없애는** 쪽으로
1430
+ 정하셨다 — 깜박이는 글은 한 마디로 묶어도 읽을 틈이 없다. 눈에는 동그라미만,
1431
+ **낭독기에는 한 마디**를 남긴다(그쪽은 깜박임이 없다).
1432
+
1433
+ `ANNOUNCED_STATUSES` 는 그대로 쓴다(사장님 결정 (나), 2026-10-05) — `준비 완료`
1434
+ 처럼 일이 끝난 뒤의 알림은 읽히지 않는다. 걸음 이름과 그 글(`STATUS_MESSAGES`)은
1435
+ **지우지 않는다**: 개발 판의 자취와 색이 그것을 쓴다. */
1436
+ _jsxs("div", { className: "audio-player__loading", role: ANNOUNCED_STATUSES[fetchDebugStatus] ? 'status' : undefined, "aria-live": ANNOUNCED_STATUSES[fetchDebugStatus] ? 'polite' : 'off', children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t('training.audioStatus.preparing', { defaultValue: '소리를 준비하고 있습니다…' }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
1425
1437
  /* 막힌 단계(아직 차례가 아닌 듣기·합성 주소를 받는 중)는 CSS pointer-events 로만 막아, Tab 으로 와서 Enter 를
1426
1438
  누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
1427
1439
  disabled: isDisabled,
@@ -135,6 +135,14 @@
135
135
  gap: 10px;
136
136
  }
137
137
 
138
+ /* 묶음 이름 머리(말귀 청 (308)) — 앱이 `초성 대비`·`모음 대비` 처럼 넘긴다.
139
+ **글자 크기는 여기서 안 정한다** — 이 저장소는 `styles.css` 의 공용 클래스(`caption-1b`)로
140
+ 준다(`date-picker__dow caption-1` 따위가 그 꼴이다). 세우다 `--font-label-m` 이라는
141
+ **없는 토큰**을 적었는데, 없는 변수는 조용히 아무 꼴도 안 내서 깨진 줄 몰랐다. */
142
+ .word-search-result-block__group-label {
143
+ color: var(--color-gray);
144
+ }
145
+
138
146
  .word-search-result-block__group + .word-search-result-block__group {
139
147
  margin-top: var(--spacing-l);
140
148
  padding-top: var(--spacing-l);
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { cx } from '../../utils/cx';
4
4
  import { compareKorean } from '../../utils/textSort';
5
5
  import { useEscToClose } from '../../hooks/useEscToClose';
6
- import { useState, useCallback, useMemo, useEffect } from 'react';
6
+ import { useState, useCallback, useMemo, useEffect, useId } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
8
  import './WordSearchResultBlock.css';
9
9
  import Button from '../Button';
@@ -126,6 +126,9 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
126
126
  const pageClamped = Math.min(page, totalPages);
127
127
  const pagedItems = useMemo(() => sortedItems.slice((pageClamped - 1) * pageSize, pageClamped * pageSize), [sortedItems, pageClamped, pageSize]);
128
128
  // 현재 페이지의 그룹 항목을 그룹 순서대로 재그룹핑한다(그룹 헤더 유지; 페이지 경계에서 한 그룹이 나뉠 수 있음).
129
+ /* 묶음 이름표의 id 뿌리 — 한 쪽에 이 묶음이 둘 이상 설 수 있어 `useId()` 로 가른다.
130
+ 이름 자체를 꼬리로 붙인다(한 쪽 안에서 `Map` 열쇠라 겹치지 않는다). */
131
+ const groupLabelBaseId = useId();
129
132
  const pagedGroups = useMemo(() => {
130
133
  if (!groups)
131
134
  return null;
@@ -180,15 +183,24 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
180
183
  과 아주 같은 h4 로 맞췄는데, 그러니 결과 덩이의 머리가 그 위 판 제목들(`검색 옵션` ·
181
184
  `단어 검색 설정`)과 같은 급이 되어 **무엇이 무엇의 머리인지 안 보였다.** 거르개 판은
182
185
  결과를 고르는 도구고 이 덩이는 그 결과라, 나란한 것이 아니라 아래 딸린 것이다. */
183
- _jsxs(Section, { type: sectionType, className: cx('word-search-result-block', className), children: [_jsxs("div", { className: "word-search-result-block__inner", children: [(titleLabel || toolbar || discNode) && (_jsxs("div", { className: "word-search-result-block__head", children: [_jsxs("div", { className: "word-search-result-block__head-main", children: [titleIcon && _jsx(Icon, { icon: titleIcon, className: "word-search-result-block__head-icon" }), titleLabel && _jsx("span", { className: "word-search-result-block__title h3", children: titleLabel })] }), toolbar && _jsx("div", { className: "word-search-result-block__head-actions", children: toolbar }), discNode && _jsx("div", { className: "word-search-result-block__head-disc body-3", children: discNode })] })), (groups ? groups.length === 0 : items.length === 0) && (_jsxs("div", { className: "word-search-result-block__empty-wrap", children: [_jsx("p", { className: "word-search-result-block__empty body-3", children: t('wordSearch.emptyResult', '검색 결과가 없습니다.') }), emptyHint && _jsx("p", { className: "word-search-result-block__empty-hint body-3", children: emptyHint })] })), groups ? ((pagedGroups ?? []).map((group) => (_jsx("div", { className: "word-search-result-block__group", children: _jsx("div", { className: "word-search-result-block__items", children: group.items.map((item) => {
184
- const isSelected = selected.includes(item.id);
185
- return (_jsx("div", { onMouseEnter: e => {
186
- openHover(e.currentTarget);
187
- setHoverWords(item.words ?? []);
188
- }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: (item.words ?? []).map(w => w.label), subLabel: (item.words ?? []).some(w => w.subLabel)
189
- ? (item.words ?? []).map(w => w.subLabel)
190
- : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
191
- }) }) }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
186
+ _jsxs(Section, { type: sectionType, className: cx('word-search-result-block', className), children: [_jsxs("div", { className: "word-search-result-block__inner", children: [(titleLabel || toolbar || discNode) && (_jsxs("div", { className: "word-search-result-block__head", children: [_jsxs("div", { className: "word-search-result-block__head-main", children: [titleIcon && _jsx(Icon, { icon: titleIcon, className: "word-search-result-block__head-icon" }), titleLabel && _jsx("span", { className: "word-search-result-block__title h3", children: titleLabel })] }), toolbar && _jsx("div", { className: "word-search-result-block__head-actions", children: toolbar }), discNode && _jsx("div", { className: "word-search-result-block__head-disc body-3", children: discNode })] })), (groups ? groups.length === 0 : items.length === 0) && (_jsxs("div", { className: "word-search-result-block__empty-wrap", children: [_jsx("p", { className: "word-search-result-block__empty body-3", children: t('wordSearch.emptyResult', '검색 결과가 없습니다.') }), emptyHint && _jsx("p", { className: "word-search-result-block__empty-hint body-3", children: emptyHint })] })), groups ? ((pagedGroups ?? []).map((group) => (
187
+ /* **묶음 이름을 머리로 그린다**(말귀 청 (308), 2026-10-19).
188
+ `pagedGroups` 가 `label` 을 들고 왔는데 **그리는 자리에서 안 썼다** — 받은 이름이
189
+ `key` 로만 쓰이고 사라져, 보는 사람은 어느 자리가 갈린 쌍인지 모른 채 한 덩어리로
190
+ 봤다(말귀 실측: 묶음 네 개를 넘겼는데 `__group` 이 1 개, 이름 글자 0 건, 1,159쌍).
191
+ `pagedGroups` 가 쪽마다 다시 묶으므로 **쪽 경계에서 나뉘면 나뉜 쪽에도 다시 선다.**
192
+
193
+ 이름을 `aria-labelledby` 로 가리킨다 — `IconGridMenu` 가 쓰는 규약이다.
194
+ `role="group"` 은 **이름이 없으면 낭독기가 묶음을 안 알린다**(`ColorPicker` 의 주석). */
195
+ _jsxs("div", { className: "word-search-result-block__group", role: "group", "aria-labelledby": `${groupLabelBaseId}-${group.label}`, children: [_jsx("div", { className: "word-search-result-block__group-label caption-1b", id: `${groupLabelBaseId}-${group.label}`, children: group.label }), _jsx("div", { className: "word-search-result-block__items", children: group.items.map((item) => {
196
+ const isSelected = selected.includes(item.id);
197
+ return (_jsx("div", { onMouseEnter: e => {
198
+ openHover(e.currentTarget);
199
+ setHoverWords(item.words ?? []);
200
+ }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: (item.words ?? []).map(w => w.label), subLabel: (item.words ?? []).some(w => w.subLabel)
201
+ ? (item.words ?? []).map(w => w.subLabel)
202
+ : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
203
+ }) })] }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
192
204
  const isSelected = selected.includes(item.id);
193
205
  return (_jsx("div", { onMouseEnter: e => {
194
206
  openHover(e.currentTarget);
@@ -470,7 +470,8 @@
470
470
  "measuring_duration": "Measuring length…",
471
471
  "ready": "Ready",
472
472
  "error": "Error",
473
- "synthesizing": "Preparing the sound…"
473
+ "synthesizing": "Preparing the sound…",
474
+ "preparing": "Preparing sound…"
474
475
  }
475
476
  },
476
477
  "trainingSettings": {
@@ -462,7 +462,8 @@
462
462
  "measuring_duration": "오디오 길이 측정…",
463
463
  "ready": "준비 완료",
464
464
  "error": "오류 발생",
465
- "synthesizing": "소리를 만들고 있습니다…"
465
+ "synthesizing": "소리를 만들고 있습니다…",
466
+ "preparing": "소리를 준비하고 있습니다…"
466
467
  }
467
468
  },
468
469
  "trainingSettings": {
@@ -19,14 +19,6 @@ export interface WordSearchResultWord {
19
19
  export interface WordSearchResultItem {
20
20
  id: string;
21
21
  words: WordSearchResultWord[];
22
- /**
23
- * 갈린 자리 — **옛 한글 이름**(`'초성'`·`'모음'`·`'종성'`·`'활음'`)이다.
24
- *
25
- * @deprecated `diffSectionCode` 를 쓴다 — 같은 자리를 코드로 가리킨다. 2026-10-13 안쪽을
26
- * 코드로 옮겼지만 **소비자가 이 값으로 묶음을 가른다**(말귀 `WordPairsPage` 의 `GROUP_ORDER`).
27
- * 혼자 바꾸면 그쪽 묶음이 조용히 빈다. 소비자가 옮긴 뒤 걷는다.
28
- */
29
- diffSection?: string;
30
22
  /** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
31
23
  diffSectionCode?: import('../utils/phonemeSectionCodes').PhonemeSectionCode;
32
24
  diffHasEmpty?: boolean;
@@ -16,7 +16,7 @@ function decompose(char) {
16
16
  /* 음절 자리 코드는 **아래 모듈**에 있다 — `phonemeConvert` 도 함께 쓰는데, 여기 두면
17
17
  `phonemePairSearch ↔ phonemeConvert` 돌림 import 가 된다(한 번 그렇게 해 시험 338 꾸러미가
18
18
  무너졌다, 2026-10-13). */
19
- import { toPhonemeCodes, toLegacyPhonemeKey, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
19
+ import { toPhonemeCodes, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
20
20
  export { toPhonemeCodes, PHONEME_NONE } from './phonemeSectionCodes';
21
21
  const SECTION_KEYS = PHONEME_SECTION_CODES;
22
22
  const NONE = PHONEME_NONE;
@@ -444,8 +444,6 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
444
444
  for (const sec of senses.get(second) ?? [second]) {
445
445
  results.push({
446
446
  id: `${wordKey(f)}|${wordKey(sec)}`,
447
- /* 옛 이름을 내보내고 코드를 나란히 — 소비자가 옛 값으로 묶음을 가른다 */
448
- diffSection: toLegacyPhonemeKey(sDiffSection),
449
447
  diffSectionCode: sDiffSection,
450
448
  diffHasEmpty: sDiffHasEmpty,
451
449
  words: [wordCells(f), wordCells(sec)],
@@ -689,7 +687,6 @@ preferAsTarget) {
689
687
  const kt = wordKey(t), kc = wordKey(c);
690
688
  results.push({
691
689
  id: kt < kc ? `${kt}|${kc}` : `${kc}|${kt}`,
692
- diffSection: toLegacyPhonemeKey(item.diffSection),
693
690
  diffSectionCode: item.diffSection,
694
691
  diffHasEmpty: item.diffHasEmpty,
695
692
  words: [wordCells(t), wordCells(c)],
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.345.0";
49
+ export declare const CORE_VERSION = "0.346.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.345.0';
49
+ export const CORE_VERSION = '0.346.0';