@ihabdevteam/core 0.180.0 → 0.182.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.
@@ -389,7 +389,17 @@ export declare interface BarChartProps {
389
389
  rate?: boolean;
390
390
  /** y축 최대를 데이터 최댓값으로 (10 단위 올림 없이). 가장 큰 막대가 차트 상단에 닿음 */
391
391
  tightMax?: boolean;
392
- /** 평균 모드 — 합계 대신 경과한 일별 a의 평균을 헤더에 표시(진행률처럼 stacked 라벨). */
392
+ /** 평균 모드 — 합계 대신 **경과한 날마다의 `a` 평균**을 머리에 보인다(진행률처럼 쌓은 라벨).
393
+ *
394
+ * 셈은 이렇다 — **막대는 이 셈에 안 들어간다:**
395
+ * - 재는 자료: 주 보기는 그 주의 막대들, **월 보기는 `data[monthRange].daily`**.
396
+ * 월 보기가 daily 인 것은 주 막대가 **달 경계를 넘기 때문**이다(월요일 시작 7일이라 마지막 주에 다음 달 날이 든다).
397
+ * - 분모: 그 자료 중 **`a !== -1` 인 칸 수** — 미래 칸만 뺀다. **기록 없는 날(0)도 분모에 든다.**
398
+ * - 분자: 그 칸들의 `a` 가 양수인 것만 더한 값.
399
+ * - 그 나눗셈을 `Math.round` 한다.
400
+ *
401
+ * 그래서 **막대 평균과는 안 맞는다** — 화면의 막대가 주 단위면 특히 그렇다(말귀 검수 2026-09-18:
402
+ * 막대 93·22·0·0 인데 머리는 12 였고, 205 / 17(경과일) = 12.06 이 그 수였다). */
393
403
  avgMode?: boolean;
394
404
  /** 헤더 합계/평균 값 포맷터(예: 분 → 'X시간 Y분'). 지정 시 단위 대신 포맷 문자열 표시. */
395
405
  totalFormat?: (n: number) => string;
@@ -1196,10 +1206,6 @@ export declare function ChoiceChips({ subs, selected, onSelect, size }: {
1196
1206
  size?: 'medium' | 'small';
1197
1207
  }): JSX.Element;
1198
1208
 
1199
- /**
1200
- * 한글 음절 분해/조립/거리 계산 유틸
1201
- * @public
1202
- */
1203
1209
  export declare const CHOSEONG: readonly ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
1204
1210
 
1205
1211
  /**
@@ -1594,7 +1600,7 @@ export declare function copyText(text: string): Promise<boolean>;
1594
1600
  *
1595
1601
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1596
1602
  */
1597
- export declare const CORE_VERSION = "0.180.0";
1603
+ export declare const CORE_VERSION = "0.182.0";
1598
1604
 
1599
1605
  /** @public */
1600
1606
  declare const coreDefault: Record<string, unknown>;
@@ -2139,16 +2145,17 @@ export declare function EffectPartyPopperHost({ trigger, active }: {
2139
2145
  /** @public 아무것도 안 고른 상태 — 시험이 기준값으로 쓴다. */
2140
2146
  export declare const EMPTY_COMPOSITION: WordComposition;
2141
2147
 
2142
- export declare function EmptyState({ icon, emoji, title, message, hint, action, compact, className }: EmptyStateProps): JSX.Element;
2148
+ export declare function EmptyState({ icon, title, message, action, compact, className }: EmptyStateProps): JSX.Element;
2143
2149
 
2144
2150
  /** @public */
2145
2151
  export declare interface EmptyStateProps {
2152
+ /** 위에 서는 아이콘 — **lucide 이름만** 받는다(`Icon` 과 같은 규약).
2153
+ * 그림문자(emoji)를 받던 `emoji` prop 은 0.182.0 에서 걷었다: 같은 자리에 두 갈래가 있으면
2154
+ * 제품마다 다른 것이 서고, 그림문자는 기기·글꼴마다 다르게 그려지며 색·크기를 토큰으로 못 맞춘다
2155
+ * (사용자 지시 2026-09-21 "아이콘은 항상 루시드 아이콘만"). */
2146
2156
  icon?: string;
2147
- emoji?: string;
2148
2157
  title?: string;
2149
2158
  message?: string;
2150
- /** 메시지 아래 보조 안내(선택). message와 별도 문단으로 렌더 — 문자열 접합 오용 방지. */
2151
- hint?: string;
2152
2159
  /**
2153
2160
  * 메시지 아래 단추(다시 시도 등). `onClick` 이 **약속(Promise)을 돌려주면 끝날 때까지(성공·실패 모두) 단추를 막는다** —
2154
2161
  * 두 번 눌러 요청이 두 번 나가지 않게.
@@ -2157,6 +2164,8 @@ export declare interface EmptyStateProps {
2157
2164
  label: string;
2158
2165
  icon?: string;
2159
2166
  onClick: () => void | Promise<unknown>;
2167
+ /** 단추 크기 — 기본 40. 빈 화면이 한 쪽을 통째로 차지하면 48·56 이 어울린다(말귀 고객관리 2026-09-21). */
2168
+ size?: 40 | 48 | 56;
2160
2169
  };
2161
2170
  /** 표 안의 빈 행처럼 좁은 자리에 넣을 때 위아래 여백을 줄인다. */
2162
2171
  compact?: boolean;
@@ -2177,7 +2186,9 @@ export declare function ErrorState({ message, hint, icon, onRetry, retryLabel, c
2177
2186
  export declare interface ErrorStateProps {
2178
2187
  /** 에러 메시지(미지정 시 공용 기본 문구). */
2179
2188
  message?: string;
2180
- /** 보조 안내(선택). */
2189
+ /** 보조 안내(선택) — "잠시 뒤 다시 시도해 보세요" 처럼 **다음에 할 일**을 적는다.
2190
+ * `EmptyState` 의 같은 줄은 0.182.0 에서 걷었지만(빈 화면은 글자 줄이 적어야 한다),
2191
+ * 실패 화면은 까닭과 다음 걸음이 갈리므로 여기서는 지킨다. */
2181
2192
  hint?: string;
2182
2193
  icon?: string;
2183
2194
  /**
@@ -2939,6 +2950,19 @@ export declare function itemCorrectness(r: RawResult): boolean | null;
2939
2950
  */
2940
2951
  export declare function jamoToNamesForSpeech(text: string): string;
2941
2952
 
2953
+ /**
2954
+ * @public 종성을 **무엇으로 견줄지.**
2955
+ *
2956
+ * - `'glyph'`(기본) — 쓰인 글자 그대로. 고른 것이 `ㅅ` 이면 받침이 `ㅅ` 인 음절만, `ㅄ` 이면 `ㅄ` 인 음절만.
2957
+ * - `'phoneme'` — **받침 소리**(대표음)로. `ㄱ` 을 고르면 `ㄱ·ㄲ·ㅋ·ㄳ·ㄺ`(박·밖·녘·넋·닭)이 함께 든다.
2958
+ *
2959
+ * 0.181.0 전에는 이름 없는 셋째 규칙이었다 — 원형이 아니면 **겹받침 구성 자모 아무거나** 로 걸렸다.
2960
+ * 그래서 `ㅅ` 에 `값(갑)·넋(넉)·몫(목)·삯(삭)` 이 들고(받침 소리는 ㅂ·ㄱ), `ㄱ` 에는 `넋·닭` 은 드는데
2961
+ * **소리가 같은 `밖·녘` 은 안 들었다** — 한쪽은 넓고 한쪽은 좁았다. 글자도 소리도 아닌 자리였다
2962
+ * (말귀 실측 2026-09-21: 라이브 23,000 낱말 중 겹받침 낱말 308개가 제 받침 소리가 아닌 자모로 한 번씩 더 걸렸다).
2963
+ */
2964
+ declare type JongMatch = 'glyph' | 'phoneme';
2965
+
2942
2966
  /** 종성 0번은 받침 없음 */
2943
2967
  export declare const JONGSEONG: readonly ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
2944
2968
 
@@ -4543,6 +4567,20 @@ export declare interface PopupProps {
4543
4567
  /** 복잡한 커스텀 footer가 필요할 때 사용. buttons가 없을 때만 렌더됩니다.
4544
4568
  * buttons와 달리 기본 폭/정렬 제약(.popup-footer--buttons)이 적용되지 않아 자유 배치 가능. */
4545
4569
  footer?: ReactNode | ((opts: PopupOptions) => ReactNode);
4570
+ /** 머리 **오른쪽에 서는 보기 고르개** — 창 하나가 갈래 둘(고객정보 · 훈련기록)을 담을 때 쓴다.
4571
+ * 닫기(X)와 `headerActions` **앞**, 제목 오른쪽에 선다. `SegmentedControl size={40}` 그대로이고,
4572
+ * 창이 좁으면 제목 아래로 줄을 바꾼다(제목을 밀어내지 않는다).
4573
+ * 이름이 없으면 낭독기가 '선택 탭' 으로 읽으니 `ariaLabel` 을 준다. */
4574
+ headerSegment?: {
4575
+ value: string;
4576
+ onChange: (key: string) => void;
4577
+ options: {
4578
+ key: string;
4579
+ label: string;
4580
+ disabled?: boolean;
4581
+ }[];
4582
+ ariaLabel?: string;
4583
+ };
4546
4584
  /** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
4547
4585
  footerClassName?: string;
4548
4586
  /** 바닥 줄의 가로 배치 — `'center'`(기본) · `'fill'` · `'between'`.
@@ -5837,8 +5875,12 @@ export declare interface SegmentedControlProps {
5837
5875
  */
5838
5876
  onChange?: (v: string) => void;
5839
5877
  className?: string;
5840
- /** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
5841
- * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다. */
5878
+ /** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). **기본 40.** **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
5879
+ * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다.
5880
+ *
5881
+ * 기본이 48 이던 것을 40 으로 내렸다(사용자 지시 2026-09-21). 코어가 제 안에서 세우는 고르개는 이미 **전부 40**
5882
+ * 이었고(`Toolbar`·`FilterPanel`·`BarChart`·`PanelRow`…), 옆에 서는 `Dropdown`·`TextField` 도 40 이다 —
5883
+ * 기본값만 혼자 48 이라 안 주고 쓴 자리가 한 급 크게 섰다. */
5842
5884
  size?: 24 | 32 | 40 | 48;
5843
5885
  shape?: 'rounded' | 'pill';
5844
5886
  useBG?: boolean;
@@ -9345,8 +9387,8 @@ export declare interface WordComposition {
9345
9387
  export declare function wordContainsChoseong(word: string, jamo: string): boolean;
9346
9388
 
9347
9389
  /** @public 단어가 특정 자모를 종성(coda)으로 포함하는지.
9348
- * 글자 입력: 겹받침 '원형'(ㅄ 등) 정확 매칭. 발음 입력: 겹받침을 구성 자모(ㅂ 등)로 분해해 대표음 매칭. */
9349
- export declare function wordContainsJongseong(word: string, jamo: string): boolean;
9390
+ * `jongMatch` 로 **글자 그대로**(기본) · **받침 소리**(`'phoneme'`)를 고른다 — `JongMatch` 설명을 본다. */
9391
+ export declare function wordContainsJongseong(word: string, jamo: string, jongMatch?: JongMatch): boolean;
9350
9392
 
9351
9393
  /** @public 단어가 특정 모음을 중성(vowel)으로 포함하는지 */
9352
9394
  export declare function wordContainsJungseong(word: string, jamo: string): boolean;
@@ -9453,7 +9495,10 @@ export declare function wordHasSyllableMatching(word: string, parts: {
9453
9495
  jung?: string[] | null;
9454
9496
  jong?: string[] | null;
9455
9497
  jongNone?: boolean;
9456
- }, position?: 'head' | 'mid' | 'tail' | null): boolean;
9498
+ }, position?: 'head' | 'mid' | 'tail' | null,
9499
+ /** 종성을 무엇으로 견줄지 — 기본은 **글자 그대로**(`'glyph'`). 받침 **소리**로 모으려면 `'phoneme'`(`JongMatch` 참고).
9500
+ * 발음 입력 화면은 넘기는 낱말 자체가 표준발음이라 겹받침이 안 나온다 — 기본 그대로 두면 된다. */
9501
+ jongMatch?: JongMatch): boolean;
9457
9502
 
9458
9503
  /** @public */
9459
9504
  export declare function WordImageTooltip({ words, anchorRect }: WordImageTooltipProps): ReactPortal | null;
@@ -9,11 +9,12 @@
9
9
  border: 1px solid var(--color-whiteLine);
10
10
  border-radius: var(--radius-m);
11
11
  }
12
- .empty-state__emoji { font-size: 40px; line-height: 1; }
13
12
  .empty-state__icon { width: 40px; height: 40px; color: var(--color-lightGray); }
14
- .empty-state__title { color: var(--color-lightGray); margin: 0; }
13
+ /* 제목과 본문은 **같은 색** 이다(사용자 지시 2026-09-21) — 전에는 제목이 한 단계 옅어(lightGray)
14
+ 제목이 본문보다 뒤로 물러나 보였다. 위계는 글자 등급(h4 · body-3)이 준다.
15
+ 같은 색으로 모을 때 gray 를 고른 것은 lightGray 가 흰 지면에서 AA(4.5:1)에 못 미치기 때문이다. */
16
+ .empty-state__title { color: var(--color-gray); margin: 0; }
15
17
  .empty-state__message { color: var(--color-gray); margin: 0; }
16
- .empty-state__hint { color: var(--color-lightGray); margin: 0; }
17
18
  .empty-state__action { margin-top: var(--spacing-xs); min-width: 120px; }
18
19
 
19
20
  /* 표의 빈 행처럼 좁은 자리 — 페이지 여백 대신 컨텐츠 여백을 쓴다 */
@@ -1,12 +1,13 @@
1
1
  import './EmptyState.css';
2
2
  /** @public */
3
3
  export interface EmptyStateProps {
4
+ /** 위에 서는 아이콘 — **lucide 이름만** 받는다(`Icon` 과 같은 규약).
5
+ * 그림문자(emoji)를 받던 `emoji` prop 은 0.182.0 에서 걷었다: 같은 자리에 두 갈래가 있으면
6
+ * 제품마다 다른 것이 서고, 그림문자는 기기·글꼴마다 다르게 그려지며 색·크기를 토큰으로 못 맞춘다
7
+ * (사용자 지시 2026-09-21 "아이콘은 항상 루시드 아이콘만"). */
4
8
  icon?: string;
5
- emoji?: string;
6
9
  title?: string;
7
10
  message?: string;
8
- /** 메시지 아래 보조 안내(선택). message와 별도 문단으로 렌더 — 문자열 접합 오용 방지. */
9
- hint?: string;
10
11
  /**
11
12
  * 메시지 아래 단추(다시 시도 등). `onClick` 이 **약속(Promise)을 돌려주면 끝날 때까지(성공·실패 모두) 단추를 막는다** —
12
13
  * 두 번 눌러 요청이 두 번 나가지 않게.
@@ -15,9 +16,11 @@ export interface EmptyStateProps {
15
16
  label: string;
16
17
  icon?: string;
17
18
  onClick: () => void | Promise<unknown>;
19
+ /** 단추 크기 — 기본 40. 빈 화면이 한 쪽을 통째로 차지하면 48·56 이 어울린다(말귀 고객관리 2026-09-21). */
20
+ size?: 40 | 48 | 56;
18
21
  };
19
22
  /** 표 안의 빈 행처럼 좁은 자리에 넣을 때 위아래 여백을 줄인다. */
20
23
  compact?: boolean;
21
24
  className?: string;
22
25
  }
23
- export default function EmptyState({ icon, emoji, title, message, hint, action, compact, className }: EmptyStateProps): import("react").JSX.Element;
26
+ export default function EmptyState({ icon, title, message, action, compact, className }: EmptyStateProps): import("react").JSX.Element;
@@ -4,9 +4,12 @@ import { cx } from '../utils/cx';
4
4
  import './EmptyState.css';
5
5
  import Icon from './Icon';
6
6
  import { usePendingPress } from '../hooks/usePendingPress';
7
- export default function EmptyState({ icon, emoji, title, message, hint, action, compact, className }) {
7
+ export default function EmptyState({ icon, title, message, action, compact, className }) {
8
8
  const { pending, press } = usePendingPress(action?.onClick);
9
- return (_jsxs("div", { className: cx('empty-state', compact && 'empty-state--compact', className), children: [emoji && _jsx("span", { className: "empty-state__emoji", children: emoji }), icon && _jsx(Icon, { icon: icon, className: "empty-state__icon" }), title && _jsx("p", { className: "empty-state__title body-2", children: title }), message && _jsx("p", { className: "empty-state__message body-3", children: message }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), action && (
9
+ return (_jsxs("div", { className: cx('empty-state', compact && 'empty-state--compact', className), children: [icon && _jsx(Icon, { icon: icon, className: "empty-state__icon" }), title && _jsx("p", { className: "empty-state__title h4", children: title }), message && _jsx("p", { className: "empty-state__message body-3", children: message }), action && (
10
10
  // type="button" — 없으면 폼 안에서 누르는 순간 폼이 제출된다
11
- _jsxs("button", { type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: press, disabled: pending, "aria-busy": pending || undefined, children: [action.icon && _jsx(Icon, { icon: action.icon, className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: action.label })] }))] }));
11
+ _jsxs("button", { type: "button",
12
+ /* 코어 `Button` 을 안 쓰고 클래스를 적는 것은 `aria-busy` 때문이다 — 약속이 끝날 때까지 알린다.
13
+ 크기만 `action.size` 로 받는다(그 값이 `button{n}`·`button{n}--bar` 두 이름을 함께 정한다). */
14
+ className: cx('empty-state__action button', `button${action.size ?? 40}`, `button${action.size ?? 40}--bar`, 'button--variant-main button--align-center button--shape-rounded'), onClick: press, disabled: pending, "aria-busy": pending || undefined, children: [action.icon && _jsx(Icon, { icon: action.icon, className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: action.label })] }))] }));
12
15
  }
@@ -1,2 +1,5 @@
1
1
  /* ErrorState — EmptyState 레이아웃(empty-state) 재사용, 에러 톤만 오버라이드 */
2
2
  .error-state__icon { color: var(--color-error); }
3
+ /* 보조 안내 — EmptyState 가 제 hint 줄을 걷으면서(0.182.0) 이 꼴을 여기로 옮겼다.
4
+ 그때까지는 `.empty-state__hint` 를 함께 쓰고 있었다. */
5
+ .error-state__hint { color: var(--color-lightGray); margin: 0; }
@@ -6,7 +6,9 @@ import './ErrorState.css';
6
6
  export interface ErrorStateProps {
7
7
  /** 에러 메시지(미지정 시 공용 기본 문구). */
8
8
  message?: string;
9
- /** 보조 안내(선택). */
9
+ /** 보조 안내(선택) — "잠시 뒤 다시 시도해 보세요" 처럼 **다음에 할 일**을 적는다.
10
+ * `EmptyState` 의 같은 줄은 0.182.0 에서 걷었지만(빈 화면은 글자 줄이 적어야 한다),
11
+ * 실패 화면은 까닭과 다음 걸음이 갈리므로 여기서는 지킨다. */
10
12
  hint?: string;
11
13
  icon?: string;
12
14
  /**
@@ -9,7 +9,7 @@ import { usePendingPress } from '../hooks/usePendingPress';
9
9
  export default function ErrorState({ message, hint, icon = 'triangle-alert', onRetry, retryLabel, className }) {
10
10
  const { t } = useTranslation();
11
11
  const { pending, press } = usePendingPress(onRetry);
12
- return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
12
+ return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "error-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
13
13
  // type="button" — 없으면 폼 안에서 다시 시도를 누르는 순간 폼이 제출된다
14
14
  type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: press, disabled: pending, "aria-busy": pending || undefined, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
15
15
  }
@@ -70,6 +70,9 @@
70
70
  .popup-header-main .popup-title--left { flex: none; }
71
71
  .popup-header .popup-title--left { margin: 0; }
72
72
  .popup-subtitle { text-align: left; color: var(--color-gray); margin: 0; }
73
+ /* 머리 오른쪽 고르개(headerSegment) — 닫기·headerActions 앞. 좁으면 제목 아래로 줄을 바꾸고
74
+ 제목을 밀어내지 않는다(제목 칸이 flex:1 로 먼저 줄어든다). */
75
+ .popup-header-segment { flex-shrink: 0; }
73
76
  .popup-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
74
77
  .popup-content {
75
78
  padding: 20px 20px 0;
@@ -36,6 +36,20 @@ export interface PopupProps {
36
36
  /** 복잡한 커스텀 footer가 필요할 때 사용. buttons가 없을 때만 렌더됩니다.
37
37
  * buttons와 달리 기본 폭/정렬 제약(.popup-footer--buttons)이 적용되지 않아 자유 배치 가능. */
38
38
  footer?: ReactNode | ((opts: PopupOptions) => ReactNode);
39
+ /** 머리 **오른쪽에 서는 보기 고르개** — 창 하나가 갈래 둘(고객정보 · 훈련기록)을 담을 때 쓴다.
40
+ * 닫기(X)와 `headerActions` **앞**, 제목 오른쪽에 선다. `SegmentedControl size={40}` 그대로이고,
41
+ * 창이 좁으면 제목 아래로 줄을 바꾼다(제목을 밀어내지 않는다).
42
+ * 이름이 없으면 낭독기가 '선택 탭' 으로 읽으니 `ariaLabel` 을 준다. */
43
+ headerSegment?: {
44
+ value: string;
45
+ onChange: (key: string) => void;
46
+ options: {
47
+ key: string;
48
+ label: string;
49
+ disabled?: boolean;
50
+ }[];
51
+ ariaLabel?: string;
52
+ };
39
53
  /** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
40
54
  footerClassName?: string;
41
55
  /** 바닥 줄의 가로 배치 — `'center'`(기본) · `'fill'` · `'between'`.
@@ -4,6 +4,7 @@ import { useCallback, useState, useEffect, useRef, useId } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { useTranslation } from "react-i18next";
6
6
  import Button from "../Button";
7
+ import SegmentedControl from "../SegmentedControl";
7
8
  import { pushEsc } from "../../hooks/escStack";
8
9
  import { lockBodyScroll } from "../../hooks/bodyScrollLock";
9
10
  import "./Popup.css";
@@ -15,7 +16,7 @@ const ANIM_MS = 260;
15
16
  /** @public */
16
17
  export default function Popup(props) {
17
18
  const { t } = useTranslation();
18
- const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", footerLayout = 'center', onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
19
+ const { title, subtitle, headerActions, headerSegment, children, buttons, footer, footerClassName = "", footerLayout = 'center', onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
19
20
  const [closing, setClosing] = useState(false);
20
21
  // 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
21
22
  // 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
@@ -211,7 +212,7 @@ export default function Popup(props) {
211
212
  e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}${height != null ? ' popup-panel--has-height' : ''}${fillBody ? ' popup-panel--fill-body' : ''}`,
212
213
  /* 높이는 **CSS 변수로** 넘긴다 — 인라인 height 로 박으면 앱의 미디어 규칙(좁은 화면만 다른 높이)이 못 이겼다.
213
214
  max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
214
- style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && useTitle && title ? titleId : undefined, "aria-label": !inline && !(useTitle && title) ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: `popup-footer popup-footer--buttons${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}`, children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
215
+ style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && useTitle && title ? titleId : undefined, "aria-label": !inline && !(useTitle && title) ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), headerSegment && (_jsx("div", { className: "popup-header-segment", children: _jsx(SegmentedControl, { size: 40, ariaLabel: headerSegment.ariaLabel, options: headerSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headerSegment.value, onChange: headerSegment.onChange }) })), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: `popup-footer popup-footer--buttons${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}`, children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
215
216
  if (inline || typeof document === 'undefined')
216
217
  return el;
217
218
  return createPortal(el, document.body);
@@ -17,8 +17,12 @@ export interface SegmentedControlProps {
17
17
  */
18
18
  onChange?: (v: string) => void;
19
19
  className?: string;
20
- /** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
21
- * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다. */
20
+ /** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). **기본 40.** **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
21
+ * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다.
22
+ *
23
+ * 기본이 48 이던 것을 40 으로 내렸다(사용자 지시 2026-09-21). 코어가 제 안에서 세우는 고르개는 이미 **전부 40**
24
+ * 이었고(`Toolbar`·`FilterPanel`·`BarChart`·`PanelRow`…), 옆에 서는 `Dropdown`·`TextField` 도 40 이다 —
25
+ * 기본값만 혼자 48 이라 안 주고 쓴 자리가 한 급 크게 섰다. */
22
26
  size?: 24 | 32 | 40 | 48;
23
27
  shape?: 'rounded' | 'pill';
24
28
  useBG?: boolean;
@@ -10,7 +10,7 @@ export default function SegmentedControl(props) {
10
10
  const { t } = useTranslation();
11
11
  const { options = [],
12
12
  // 안 주면 **켜진** 첫 칸 — 꺼진 첫 칸이 골라져 있었다(Tabs 와 같게)
13
- value = options.find((o) => !o.disabled)?.key ?? options[0]?.key, onChange = (_v) => { }, className = '', size = 48, shape = 'pill', useBG = true, fullWidth = false, minButtonWidth, ariaLabel, } = props || {};
13
+ value = options.find((o) => !o.disabled)?.key ?? options[0]?.key, onChange = (_v) => { }, className = '', size = 40, shape = 'pill', useBG = true, fullWidth = false, minButtonWidth, ariaLabel, } = props || {};
14
14
  /* **라디오 무리는 Tab 자리 하나 + 화살표**가 ARIA 규칙이다.
15
15
  `role="radiogroup"`·`role="radio"` 만 붙여 놓고 키를 안 만들면 **안 붙인 것보다
16
16
  나쁘다** — 낭독기가 "라디오 그룹, 3 중 2" 라고 읽어 주니 사람은 화살표를 누르는데
@@ -74,7 +74,17 @@ export interface BarChartProps {
74
74
  rate?: boolean;
75
75
  /** y축 최대를 데이터 최댓값으로 (10 단위 올림 없이). 가장 큰 막대가 차트 상단에 닿음 */
76
76
  tightMax?: boolean;
77
- /** 평균 모드 — 합계 대신 경과한 일별 a의 평균을 헤더에 표시(진행률처럼 stacked 라벨). */
77
+ /** 평균 모드 — 합계 대신 **경과한 날마다의 `a` 평균**을 머리에 보인다(진행률처럼 쌓은 라벨).
78
+ *
79
+ * 셈은 이렇다 — **막대는 이 셈에 안 들어간다:**
80
+ * - 재는 자료: 주 보기는 그 주의 막대들, **월 보기는 `data[monthRange].daily`**.
81
+ * 월 보기가 daily 인 것은 주 막대가 **달 경계를 넘기 때문**이다(월요일 시작 7일이라 마지막 주에 다음 달 날이 든다).
82
+ * - 분모: 그 자료 중 **`a !== -1` 인 칸 수** — 미래 칸만 뺀다. **기록 없는 날(0)도 분모에 든다.**
83
+ * - 분자: 그 칸들의 `a` 가 양수인 것만 더한 값.
84
+ * - 그 나눗셈을 `Math.round` 한다.
85
+ *
86
+ * 그래서 **막대 평균과는 안 맞는다** — 화면의 막대가 주 단위면 특히 그렇다(말귀 검수 2026-09-18:
87
+ * 막대 93·22·0·0 인데 머리는 12 였고, 205 / 17(경과일) = 12.06 이 그 수였다). */
78
88
  avgMode?: boolean;
79
89
  /** 헤더 합계/평균 값 포맷터(예: 분 → 'X시간 Y분'). 지정 시 단위 대신 포맷 문자열 표시. */
80
90
  totalFormat?: (n: number) => string;
@@ -1,8 +1,8 @@
1
1
  import type { CompositionPart } from '../utils/koreanSyllable';
2
2
  import { type PhonemeGroupItem, type PhoneticFeature } from '../utils/phonemeGroups';
3
- export declare const FULL_CHOSEONG: ("ㄱ" | "ㄲ" | "ㄴ" | "ㄷ" | "ㄸ" | "ㄹ" | "ㅁ" | "ㅂ" | "ㅃ" | "ㅅ" | "ㅆ" | "ㅇ" | "ㅈ" | "ㅉ" | "ㅊ" | "ㅋ" | "ㅌ" | "ㅍ" | "ㅎ")[];
4
- export declare const FULL_JUNGSEONG: ("ㅏ" | "ㅐ" | "ㅑ" | "ㅒ" | "ㅓ" | "ㅔ" | "ㅕ" | "ㅖ" | "ㅗ" | "ㅘ" | "ㅙ" | "ㅚ" | "ㅛ" | "ㅜ" | "ㅝ" | "ㅞ" | "ㅟ" | "ㅠ" | "ㅡ" | "ㅢ" | "ㅣ")[];
5
- export declare const FULL_JONGSEONG: ("ㄱ" | "ㄲ" | "ㄴ" | "ㄷ" | "ㄹ" | "ㅁ" | "ㅂ" | "ㅅ" | "ㅆ" | "ㅇ" | "ㅈ" | "ㅊ" | "ㅋ" | "ㅌ" | "ㅍ" | "ㅎ" | "ㄳ" | "ㄵ" | "ㄶ" | "ㄺ" | "ㄻ" | "ㄼ" | "ㄽ" | "ㄾ" | "ㄿ" | "ㅀ" | "ㅄ")[];
3
+ export declare const FULL_CHOSEONG: ("ㄱ" | "ㄲ" | "ㅋ" | "ㄴ" | "ㄷ" | "ㅅ" | "ㅆ" | "ㅈ" | "ㅊ" | "ㅌ" | "ㅎ" | "ㄹ" | "ㅁ" | "ㅂ" | "ㅍ" | "ㅇ" | "ㄸ" | "ㅃ" | "ㅉ")[];
4
+ export declare const FULL_JUNGSEONG: ("ㅏ" | "ㅓ" | "ㅗ" | "ㅜ" | "ㅣ" | "ㅡ" | "ㅑ" | "ㅕ" | "ㅛ" | "ㅠ" | "ㅐ" | "ㅒ" | "ㅔ" | "ㅖ" | "ㅘ" | "ㅙ" | "ㅚ" | "ㅝ" | "ㅞ" | "ㅟ" | "ㅢ")[];
5
+ export declare const FULL_JONGSEONG: ("ㄱ" | "ㄲ" | "ㅋ" | "ㄳ" | "ㄺ" | "ㄴ" | "ㄵ" | "ㄶ" | "ㄷ" | "ㅅ" | "ㅆ" | "ㅈ" | "ㅊ" | "ㅌ" | "ㅎ" | "ㄹ" | "ㄼ" | "ㄽ" | "ㄾ" | "ㅀ" | "ㅁ" | "ㄻ" | "ㅂ" | "ㅍ" | "ㄿ" | "ㅄ" | "ㅇ")[];
6
6
  /** 선택 값(자모 문자열, 병합 버튼은 다자모)을 실제 매칭 자모 배열로 분해. NO_CODA는 빈 배열. */
7
7
  export declare const valueToJamos: (v: string) => string[];
8
8
  /** 종성 '제외'(받침 없는 단어만) 선택을 나타내는 센티넬 값 */
@@ -1,7 +1,3 @@
1
- /**
2
- * 한글 음절 분해/조립/거리 계산 유틸
3
- * @public
4
- */
5
1
  export declare const CHOSEONG: readonly ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
6
2
  export declare const JUNGSEONG: readonly ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"];
7
3
  /** 종성 0번은 받침 없음 */
@@ -63,9 +59,21 @@ export declare function wordContainsPhoneme(word: string, jamo: string): boolean
63
59
  export declare function wordContainsChoseong(word: string, jamo: string): boolean;
64
60
  /** @public 단어가 특정 모음을 중성(vowel)으로 포함하는지 */
65
61
  export declare function wordContainsJungseong(word: string, jamo: string): boolean;
62
+ /**
63
+ * @public 종성을 **무엇으로 견줄지.**
64
+ *
65
+ * - `'glyph'`(기본) — 쓰인 글자 그대로. 고른 것이 `ㅅ` 이면 받침이 `ㅅ` 인 음절만, `ㅄ` 이면 `ㅄ` 인 음절만.
66
+ * - `'phoneme'` — **받침 소리**(대표음)로. `ㄱ` 을 고르면 `ㄱ·ㄲ·ㅋ·ㄳ·ㄺ`(박·밖·녘·넋·닭)이 함께 든다.
67
+ *
68
+ * 0.181.0 전에는 이름 없는 셋째 규칙이었다 — 원형이 아니면 **겹받침 구성 자모 아무거나** 로 걸렸다.
69
+ * 그래서 `ㅅ` 에 `값(갑)·넋(넉)·몫(목)·삯(삭)` 이 들고(받침 소리는 ㅂ·ㄱ), `ㄱ` 에는 `넋·닭` 은 드는데
70
+ * **소리가 같은 `밖·녘` 은 안 들었다** — 한쪽은 넓고 한쪽은 좁았다. 글자도 소리도 아닌 자리였다
71
+ * (말귀 실측 2026-09-21: 라이브 23,000 낱말 중 겹받침 낱말 308개가 제 받침 소리가 아닌 자모로 한 번씩 더 걸렸다).
72
+ */
73
+ export type JongMatch = 'glyph' | 'phoneme';
66
74
  /** @public 단어가 특정 자모를 종성(coda)으로 포함하는지.
67
- * 글자 입력: 겹받침 '원형'(ㅄ 등) 정확 매칭. 발음 입력: 겹받침을 구성 자모(ㅂ 등)로 분해해 대표음 매칭. */
68
- export declare function wordContainsJongseong(word: string, jamo: string): boolean;
75
+ * `jongMatch` 로 **글자 그대로**(기본) · **받침 소리**(`'phoneme'`)를 고른다 — `JongMatch` 설명을 본다. */
76
+ export declare function wordContainsJongseong(word: string, jamo: string, jongMatch?: JongMatch): boolean;
69
77
  /** @public 단어의 한글 음절 개수 (한글 이외 문자는 제외) */
70
78
  export declare function syllableLength(word: string): number;
71
79
  /** @public 단어의 모든 한글 음절에 종성(받침)이 없는지 (열린 음절로만 구성) */
@@ -81,7 +89,10 @@ export declare function wordHasSyllableMatching(word: string, parts: {
81
89
  jung?: string[] | null;
82
90
  jong?: string[] | null;
83
91
  jongNone?: boolean;
84
- }, position?: 'head' | 'mid' | 'tail' | null): boolean;
92
+ }, position?: 'head' | 'mid' | 'tail' | null,
93
+ /** 종성을 무엇으로 견줄지 — 기본은 **글자 그대로**(`'glyph'`). 받침 **소리**로 모으려면 `'phoneme'`(`JongMatch` 참고).
94
+ * 발음 입력 화면은 넘기는 낱말 자체가 표준발음이라 겹받침이 안 나온다 — 기본 그대로 두면 된다. */
95
+ jongMatch?: JongMatch): boolean;
85
96
  /**
86
97
  * @public 문자열이 음절 패턴 DSL 문법인지 — 쉼표(,) 포함 여부로 판별한다.
87
98
  * 일반 단어 검색어엔 쉼표가 쓰이지 않으므로 충돌 없이 자동 분기할 수 있다.
@@ -2,6 +2,7 @@
2
2
  * 한글 음절 분해/조립/거리 계산 유틸
3
3
  * @public
4
4
  */
5
+ import { finalToPhoneme } from './phonemeConvert';
5
6
  export const CHOSEONG = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
6
7
  export const JUNGSEONG = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
7
8
  /** 종성 0번은 받침 없음 */
@@ -238,20 +239,24 @@ export function wordContainsJungseong(word, jamo) {
238
239
  }
239
240
  return false;
240
241
  }
242
+ /** 음절의 종성이 고른 자모 하나와 맞는지 — 두 모드의 단일 출처. */
243
+ function jongMatchesJamo(jong, jamo, mode) {
244
+ const 글자 = JONGSEONG[jong];
245
+ if (글자 === jamo)
246
+ return true;
247
+ return mode === 'phoneme' && finalToPhoneme(글자) === finalToPhoneme(jamo);
248
+ }
241
249
  /** @public 단어가 특정 자모를 종성(coda)으로 포함하는지.
242
- * 글자 입력: 겹받침 '원형'(ㅄ 등) 정확 매칭. 발음 입력: 겹받침을 구성 자모(ㅂ 등)로 분해해 대표음 매칭. */
243
- export function wordContainsJongseong(word, jamo) {
250
+ * `jongMatch` 로 **글자 그대로**(기본) · **받침 소리**(`'phoneme'`)를 고른다 — `JongMatch` 설명을 본다. */
251
+ export function wordContainsJongseong(word, jamo, jongMatch = 'glyph') {
244
252
  if (!word || !jamo)
245
253
  return false;
246
254
  for (const ch of word) {
247
255
  const s = parseSyllable(ch);
248
256
  if (!s || s.jong <= 0)
249
257
  continue;
250
- if (JONGSEONG[s.jong] === jamo)
251
- return true; // 겹받침 원형 포함 정확 매칭
252
- const decomp = JONG_DECOMPOSE[s.jong];
253
- if (decomp && decomp.includes(jamo))
254
- return true; // 겹받침 구성 자모(대표음) 매칭
258
+ if (jongMatchesJamo(s.jong, jamo, jongMatch))
259
+ return true;
255
260
  }
256
261
  return false;
257
262
  }
@@ -280,15 +285,11 @@ export function wordHasNoCoda(word) {
280
285
  }
281
286
  return sawSyllable;
282
287
  }
283
- /** 한 음절(Syllable)이 주어진 종성 자모 집합 중 하나를 종성으로 갖는지.
284
- * 글자 입력: 겹받침 원형 정확 매칭. 발음 입력: 겹받침 구성 자모(대표음)도 매칭. */
285
- function syllableJongMatches(s, jamos) {
288
+ /** 한 음절(Syllable)이 주어진 종성 자모 집합 중 하나를 종성으로 갖는지(`JongMatch` 규칙). */
289
+ function syllableJongMatches(s, jamos, mode) {
286
290
  if (s.jong <= 0)
287
291
  return false;
288
- if (jamos.includes(JONGSEONG[s.jong]))
289
- return true;
290
- const decomp = JONG_DECOMPOSE[s.jong];
291
- return !!decomp && decomp.some(j => jamos.includes(j));
292
+ return jamos.some(j => jongMatchesJamo(s.jong, j, mode));
292
293
  }
293
294
  /**
294
295
  * @public 단어에 '하나의 음절'이 주어진 초성/중성/종성 제약을 **모두** 만족하는지.
@@ -298,7 +299,10 @@ function syllableJongMatches(s, jamos) {
298
299
  */
299
300
  export function wordHasSyllableMatching(word, parts,
300
301
  // 위치 제약(선택) — 'head'=어두(첫음절), 'tail'=어말(끝음절), 'mid'=어중(가운데, 3음절+). 미지정=모든 음절.
301
- position) {
302
+ position,
303
+ /** 종성을 무엇으로 견줄지 — 기본은 **글자 그대로**(`'glyph'`). 받침 **소리**로 모으려면 `'phoneme'`(`JongMatch` 참고).
304
+ * 발음 입력 화면은 넘기는 낱말 자체가 표준발음이라 겹받침이 안 나온다 — 기본 그대로 두면 된다. */
305
+ jongMatch = 'glyph') {
302
306
  if (!word)
303
307
  return false;
304
308
  const chars = [...word];
@@ -314,7 +318,7 @@ position) {
314
318
  if (s.jong > 0)
315
319
  return false;
316
320
  }
317
- else if (parts.jong && !syllableJongMatches(s, parts.jong))
321
+ else if (parts.jong && !syllableJongMatches(s, parts.jong, jongMatch))
318
322
  return false;
319
323
  return true;
320
324
  };
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.180.0";
49
+ export declare const CORE_VERSION = "0.182.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.180.0';
49
+ export const CORE_VERSION = '0.182.0';