@ihabdevteam/core 0.179.0 → 0.181.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.179.0";
1603
+ export declare const CORE_VERSION = "0.181.0";
1598
1604
 
1599
1605
  /** @public */
1600
1606
  declare const coreDefault: Record<string, unknown>;
@@ -2157,6 +2163,8 @@ export declare interface EmptyStateProps {
2157
2163
  label: string;
2158
2164
  icon?: string;
2159
2165
  onClick: () => void | Promise<unknown>;
2166
+ /** 단추 크기 — 기본 40. 빈 화면이 한 쪽을 통째로 차지하면 48·56 이 어울린다(말귀 고객관리 2026-09-21). */
2167
+ size?: 40 | 48 | 56;
2160
2168
  };
2161
2169
  /** 표 안의 빈 행처럼 좁은 자리에 넣을 때 위아래 여백을 줄인다. */
2162
2170
  compact?: boolean;
@@ -2939,6 +2947,19 @@ export declare function itemCorrectness(r: RawResult): boolean | null;
2939
2947
  */
2940
2948
  export declare function jamoToNamesForSpeech(text: string): string;
2941
2949
 
2950
+ /**
2951
+ * @public 종성을 **무엇으로 견줄지.**
2952
+ *
2953
+ * - `'glyph'`(기본) — 쓰인 글자 그대로. 고른 것이 `ㅅ` 이면 받침이 `ㅅ` 인 음절만, `ㅄ` 이면 `ㅄ` 인 음절만.
2954
+ * - `'phoneme'` — **받침 소리**(대표음)로. `ㄱ` 을 고르면 `ㄱ·ㄲ·ㅋ·ㄳ·ㄺ`(박·밖·녘·넋·닭)이 함께 든다.
2955
+ *
2956
+ * 0.181.0 전에는 이름 없는 셋째 규칙이었다 — 원형이 아니면 **겹받침 구성 자모 아무거나** 로 걸렸다.
2957
+ * 그래서 `ㅅ` 에 `값(갑)·넋(넉)·몫(목)·삯(삭)` 이 들고(받침 소리는 ㅂ·ㄱ), `ㄱ` 에는 `넋·닭` 은 드는데
2958
+ * **소리가 같은 `밖·녘` 은 안 들었다** — 한쪽은 넓고 한쪽은 좁았다. 글자도 소리도 아닌 자리였다
2959
+ * (말귀 실측 2026-09-21: 라이브 23,000 낱말 중 겹받침 낱말 308개가 제 받침 소리가 아닌 자모로 한 번씩 더 걸렸다).
2960
+ */
2961
+ declare type JongMatch = 'glyph' | 'phoneme';
2962
+
2942
2963
  /** 종성 0번은 받침 없음 */
2943
2964
  export declare const JONGSEONG: readonly ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
2944
2965
 
@@ -3788,6 +3809,15 @@ declare type PanelRowChildren = {
3788
3809
  children?: ReactNode;
3789
3810
  align?: 'horizontal' | 'vertical';
3790
3811
  icon?: ReactNode;
3812
+ /** 라벨을 고정 폭으로 두고 **오른쪽이 남는 폭을 채운다** — 그 안의 입력칸도 함께 늘어난다.
3813
+ *
3814
+ * 기본은 오른쪽이 **제 폭만** 차지한다(`flex: 0 0 auto`). 그래서 `TextField`·`Dropdown`·`form-input` 을
3815
+ * children 으로 넣으면 줄마다 오른쪽 끝이 들쭉날쭉했고, 소비자가 `.panel-row__right { display: flex }` +
3816
+ * `> * { flex: 1 }` 을 손으로 붙여 왔다 — 같은 한 줄이 말귀 앱 넷 · 어드민 셋에 흩어져 있었다(2026-09-18 전수).
3817
+ *
3818
+ * `variant='input'`·`'dropdown'` 행은 늘 이 꼴이고, `'text'`·`'custom'` 은 같은 이름의 prop 으로 켠다 —
3819
+ * children 모드에만 통로가 없었다. 라벨 폭은 `Panel labelWidth` 로 정한다(기본 120px). */
3820
+ useFullWidth?: boolean;
3791
3821
  };
3792
3822
 
3793
3823
  declare type PanelRowCustom = Base & {
@@ -5750,7 +5780,7 @@ export declare interface SearchSummaryProps {
5750
5780
  export declare type SearchSummaryStatus = 'ready' | 'guide' | 'error';
5751
5781
 
5752
5782
  /** @public */
5753
- export declare function Section({ type, title, headingLevel, icon, action, disc, children, className, style }: SectionProps): JSX.Element;
5783
+ export declare function Section({ type, title, headingLevel, icon, action, disc, children, inGrid, className, style }: SectionProps): JSX.Element;
5754
5784
 
5755
5785
  /**
5756
5786
  * 섹션·페이지의 머리 — 머리말 · 제목 · 설명.
@@ -5798,6 +5828,17 @@ export declare interface SectionProps {
5798
5828
  action?: ReactNode;
5799
5829
  disc?: ReactNode;
5800
5830
  children?: ReactNode;
5831
+ /**
5832
+ * CSS 격자(한 줄에 여러 칸) 안에 세울 때 켠다 — 아래 여백(`--spacing-page`)을 푼다.
5833
+ *
5834
+ * `Section` 은 **세로로 이어 놓는 것**을 전제로 아래 여백을 두고, 마지막 것만 `:last-child` 로 뺀다.
5835
+ * 격자에 넣으면 그 규칙이 **마지막 칸에만** 걸려 넷 중 셋만 여백을 달고 선다 — 카드 바닥선이 어긋나고
5836
+ * 줄 사이 간격도 달라 보인다(말귀 문장훈련 분석 2×2 실측 2026-09-18: 카드 506 vs 566px).
5837
+ * 격자에서는 칸 사이를 `gap` 이 맡으므로 이 여백은 할 일이 없다.
5838
+ *
5839
+ * `ChartSection inGrid` 와 같은 뜻이다(그쪽은 최소 폭·바닥 맞춤도 함께 푼다).
5840
+ */
5841
+ inGrid?: boolean;
5801
5842
  className?: string;
5802
5843
  style?: CSSProperties;
5803
5844
  }
@@ -9325,8 +9366,8 @@ export declare interface WordComposition {
9325
9366
  export declare function wordContainsChoseong(word: string, jamo: string): boolean;
9326
9367
 
9327
9368
  /** @public 단어가 특정 자모를 종성(coda)으로 포함하는지.
9328
- * 글자 입력: 겹받침 '원형'(ㅄ 등) 정확 매칭. 발음 입력: 겹받침을 구성 자모(ㅂ 등)로 분해해 대표음 매칭. */
9329
- export declare function wordContainsJongseong(word: string, jamo: string): boolean;
9369
+ * `jongMatch` 로 **글자 그대로**(기본) · **받침 소리**(`'phoneme'`)를 고른다 — `JongMatch` 설명을 본다. */
9370
+ export declare function wordContainsJongseong(word: string, jamo: string, jongMatch?: JongMatch): boolean;
9330
9371
 
9331
9372
  /** @public 단어가 특정 모음을 중성(vowel)으로 포함하는지 */
9332
9373
  export declare function wordContainsJungseong(word: string, jamo: string): boolean;
@@ -9433,7 +9474,10 @@ export declare function wordHasSyllableMatching(word: string, parts: {
9433
9474
  jung?: string[] | null;
9434
9475
  jong?: string[] | null;
9435
9476
  jongNone?: boolean;
9436
- }, position?: 'head' | 'mid' | 'tail' | null): boolean;
9477
+ }, position?: 'head' | 'mid' | 'tail' | null,
9478
+ /** 종성을 무엇으로 견줄지 — 기본은 **글자 그대로**(`'glyph'`). 받침 **소리**로 모으려면 `'phoneme'`(`JongMatch` 참고).
9479
+ * 발음 입력 화면은 넘기는 낱말 자체가 표준발음이라 겹받침이 안 나온다 — 기본 그대로 두면 된다. */
9480
+ jongMatch?: JongMatch): boolean;
9437
9481
 
9438
9482
  /** @public */
9439
9483
  export declare function WordImageTooltip({ words, anchorRect }: WordImageTooltipProps): ReactPortal | null;
@@ -9742,7 +9786,7 @@ export declare type WordTableOrientation = 'landscape' | 'portrait';
9742
9786
  * 표 내부에는 제목을 두지 않아 팝업 헤더 제목과 중복되지 않는다.
9743
9787
  * @public
9744
9788
  */
9745
- export declare function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): JSX.Element;
9789
+ export declare function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, footerLayout, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): JSX.Element;
9746
9790
 
9747
9791
  /** @public */
9748
9792
  export declare interface WordTablePopupActions {
@@ -9774,6 +9818,10 @@ export declare interface WordTablePopupProps {
9774
9818
  renderFooter?: (actions: WordTablePopupActions) => PopupProps['footer'];
9775
9819
  /** renderFooter 사용 시 footer 래퍼에 추가할 클래스(정렬·간격 override용). */
9776
9820
  footerClassName?: string;
9821
+ /** 바닥 줄의 가로 배치 — `Popup footerLayout` 을 그대로 내려보낸다(`'center'` 기본 · `'fill'` · `'between'`).
9822
+ * 이 감싸개만 `footerClassName` 을 소비자에게 열고 있어, 여기 없으면 **이 창만** 앱 CSS 로 정렬을 칠해야 했다
9823
+ * (말귀 단어표 미리보기 창, 2026-09-18). */
9824
+ footerLayout?: PopupProps['footerLayout'];
9777
9825
  /** 지정하면 하단에 "인쇄하기"·"새 창으로 보기" 버튼을 추가하고, 이 값을 PDF 저장 기본 파일명으로 쓴다. */
9778
9826
  printFileName?: string;
9779
9827
  /** 표 위에 '표 설정'(구성·용지 방향·외곽선 등) FilterPanel을 노출한다. */
@@ -15,6 +15,8 @@ export interface EmptyStateProps {
15
15
  label: string;
16
16
  icon?: string;
17
17
  onClick: () => void | Promise<unknown>;
18
+ /** 단추 크기 — 기본 40. 빈 화면이 한 쪽을 통째로 차지하면 48·56 이 어울린다(말귀 고객관리 2026-09-21). */
19
+ size?: 40 | 48 | 56;
18
20
  };
19
21
  /** 표 안의 빈 행처럼 좁은 자리에 넣을 때 위아래 여백을 줄인다. */
20
22
  compact?: boolean;
@@ -8,5 +8,8 @@ export default function EmptyState({ icon, emoji, title, message, hint, action,
8
8
  const { pending, press } = usePendingPress(action?.onClick);
9
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 && (
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
  }
@@ -236,6 +236,25 @@
236
236
  flex: 1;
237
237
  min-width: 0;
238
238
  }
239
+ /* 오른쪽 칸이 남은 폭을 채우는 줄(useFullWidth) — **그 안의 것도 함께 늘어난다.**
240
+ 오른쪽 칸만 늘리면 입력칸은 제 폭에 머물러 줄마다 끝이 들쭉날쭉했고, 소비자 일곱 자리가 이
241
+ `flex: 1` 한 줄을 각자 붙이고 있었다(말귀 앱 넷 · 어드민 셋, 2026-09-18 전수).
242
+ 직계만 본다 — 안쪽 어디까지 늘릴지는 그 입력칸이 정한다(`TextField` 는 제 감싸개를 채운다). */
243
+ .panel-row--fill .panel-row__right {
244
+ display: flex;
245
+ align-items: center;
246
+ }
247
+ .panel-row--fill .panel-row__right > * {
248
+ flex: 1;
249
+ min-width: 0;
250
+ }
251
+ /* 입력칸을 감싸개 없이 줄에 바로 넣는 자리도 같이 본다(앱이 `.panel-row > .text-field` 를 덮던 꼴) */
252
+ .panel-row--fill > .text-field,
253
+ .panel-row--fill > .dropdown,
254
+ .panel-row--fill > .form-input {
255
+ flex: 1;
256
+ min-width: 0;
257
+ }
239
258
  .panel-row--clickable {
240
259
  cursor: pointer;
241
260
  transition: background 0.1s, box-shadow 0.1s;
@@ -130,6 +130,15 @@ export type PanelRowChildren = {
130
130
  children?: ReactNode;
131
131
  align?: 'horizontal' | 'vertical';
132
132
  icon?: ReactNode;
133
+ /** 라벨을 고정 폭으로 두고 **오른쪽이 남는 폭을 채운다** — 그 안의 입력칸도 함께 늘어난다.
134
+ *
135
+ * 기본은 오른쪽이 **제 폭만** 차지한다(`flex: 0 0 auto`). 그래서 `TextField`·`Dropdown`·`form-input` 을
136
+ * children 으로 넣으면 줄마다 오른쪽 끝이 들쭉날쭉했고, 소비자가 `.panel-row__right { display: flex }` +
137
+ * `> * { flex: 1 }` 을 손으로 붙여 왔다 — 같은 한 줄이 말귀 앱 넷 · 어드민 셋에 흩어져 있었다(2026-09-18 전수).
138
+ *
139
+ * `variant='input'`·`'dropdown'` 행은 늘 이 꼴이고, `'text'`·`'custom'` 은 같은 이름의 prop 으로 켠다 —
140
+ * children 모드에만 통로가 없었다. 라벨 폭은 `Panel labelWidth` 로 정한다(기본 120px). */
141
+ useFullWidth?: boolean;
133
142
  };
134
143
  export type PanelRowProps = PanelRowToggle | PanelRowSegmented | PanelRowDropdown | PanelRowButton | PanelRowText | PanelRowInput | PanelRowCustom | PanelRowDescription | PanelRowEmpty | PanelRowLoading | PanelRowChildren;
135
144
  export declare function PanelRow(props: PanelRowProps): import("react").JSX.Element;
@@ -27,7 +27,7 @@ export function PanelRow(props) {
27
27
  const uid = useId();
28
28
  // children 기반 하위 호환 모드
29
29
  if (!('variant' in props)) {
30
- const { label, sub, onClick, children, align = 'horizontal', icon } = props;
30
+ const { label, sub, onClick, children, align = 'horizontal', icon, useFullWidth = false } = props;
31
31
  const handleKeyDown = (e) => {
32
32
  if (!onClick)
33
33
  return;
@@ -40,7 +40,7 @@ export function PanelRow(props) {
40
40
  onClick();
41
41
  }
42
42
  };
43
- return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), label != null && (_jsxs("div", { className: "panel-row__left", children: [_jsx("div", { className: "panel-row__label body-3", children: label }), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] })), _jsx("div", { className: "panel-row__right", children: children })] }));
43
+ return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', useFullWidth ? 'panel-row--label-fixed panel-row--fill' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), label != null && (_jsxs("div", { className: "panel-row__left", children: [_jsx("div", { className: "panel-row__label body-3", children: label }), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] })), _jsx("div", { className: "panel-row__right", children: children })] }));
44
44
  }
45
45
  // 설명 전용 행 — 라벨/우측 구조 없이 문단만 렌더
46
46
  if (props.variant === 'description') {
@@ -12,6 +12,10 @@
12
12
 
13
13
  .section:last-child { padding-bottom: 0; }
14
14
 
15
+ /* 격자 칸 안(inGrid) — 아래 여백을 푼다. `:last-child` 는 격자에서 **마지막 칸에만** 걸려
16
+ 넷 중 셋만 여백을 달고 선다(카드 바닥선이 어긋난다). 칸 사이는 격자의 gap 이 맡는다. */
17
+ .section--in-grid { padding-bottom: 0; }
18
+
15
19
  .section {
16
20
  }
17
21
 
@@ -15,8 +15,19 @@ export interface SectionProps {
15
15
  action?: ReactNode;
16
16
  disc?: ReactNode;
17
17
  children?: ReactNode;
18
+ /**
19
+ * CSS 격자(한 줄에 여러 칸) 안에 세울 때 켠다 — 아래 여백(`--spacing-page`)을 푼다.
20
+ *
21
+ * `Section` 은 **세로로 이어 놓는 것**을 전제로 아래 여백을 두고, 마지막 것만 `:last-child` 로 뺀다.
22
+ * 격자에 넣으면 그 규칙이 **마지막 칸에만** 걸려 넷 중 셋만 여백을 달고 선다 — 카드 바닥선이 어긋나고
23
+ * 줄 사이 간격도 달라 보인다(말귀 문장훈련 분석 2×2 실측 2026-09-18: 카드 506 vs 566px).
24
+ * 격자에서는 칸 사이를 `gap` 이 맡으므로 이 여백은 할 일이 없다.
25
+ *
26
+ * `ChartSection inGrid` 와 같은 뜻이다(그쪽은 최소 폭·바닥 맞춤도 함께 푼다).
27
+ */
28
+ inGrid?: boolean;
18
29
  className?: string;
19
30
  style?: CSSProperties;
20
31
  }
21
32
  /** @public */
22
- export default function Section({ type, title, headingLevel, icon, action, disc, children, className, style }: SectionProps): import("react").JSX.Element;
33
+ export default function Section({ type, title, headingLevel, icon, action, disc, children, inGrid, className, style }: SectionProps): import("react").JSX.Element;
@@ -8,7 +8,7 @@ const TITLE_CLASS = {
8
8
  popup: 'h5',
9
9
  };
10
10
  /** @public */
11
- export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, className, style }) {
11
+ export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, inGrid = false, className, style }) {
12
12
  const titleClass = TITLE_CLASS[type];
13
13
  // action·icon 만 줘도 머리를 그린다 — 제목·설명이 없으면 추가 단추가 조용히 버려졌다
14
14
  const hasHeader = title || disc || action || icon;
@@ -16,5 +16,5 @@ export default function Section({ type = 'page', title, headingLevel, icon, acti
16
16
  부르는 쪽이 정한다 — 안 주면 예전 그대로 div. 모양은 클래스(h3/h4/h5)가 정하고
17
17
  제목 태그의 기본 여백은 .section__title 의 margin:0 이 지운다. */
18
18
  const TitleTag = headingLevel ? `h${headingLevel}` : 'div';
19
- return (_jsxs("div", { className: cx('section', `section--${type}`, className), style: style, children: [hasHeader && (_jsxs("div", { className: "section__header", children: [_jsxs("div", { className: "section__title-row", children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "section__icon" })), _jsxs("div", { className: "section__title-group", children: [title && _jsx(TitleTag, { className: `section__title ${titleClass}`, children: title }), disc && _jsx("div", { className: "section__disc body-3", children: disc })] })] }), action && _jsx("div", { className: "section__action", children: action })] })), children] }));
19
+ return (_jsxs("div", { className: cx('section', `section--${type}`, inGrid && 'section--in-grid', className), style: style, children: [hasHeader && (_jsxs("div", { className: "section__header", children: [_jsxs("div", { className: "section__title-row", children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "section__icon" })), _jsxs("div", { className: "section__title-group", children: [title && _jsx(TitleTag, { className: `section__title ${titleClass}`, children: title }), disc && _jsx("div", { className: "section__disc body-3", children: disc })] })] }), action && _jsx("div", { className: "section__action", children: action })] })), children] }));
20
20
  }
@@ -32,6 +32,10 @@ export interface WordTablePopupProps {
32
32
  renderFooter?: (actions: WordTablePopupActions) => PopupProps['footer'];
33
33
  /** renderFooter 사용 시 footer 래퍼에 추가할 클래스(정렬·간격 override용). */
34
34
  footerClassName?: string;
35
+ /** 바닥 줄의 가로 배치 — `Popup footerLayout` 을 그대로 내려보낸다(`'center'` 기본 · `'fill'` · `'between'`).
36
+ * 이 감싸개만 `footerClassName` 을 소비자에게 열고 있어, 여기 없으면 **이 창만** 앱 CSS 로 정렬을 칠해야 했다
37
+ * (말귀 단어표 미리보기 창, 2026-09-18). */
38
+ footerLayout?: PopupProps['footerLayout'];
35
39
  /** 지정하면 하단에 "인쇄하기"·"새 창으로 보기" 버튼을 추가하고, 이 값을 PDF 저장 기본 파일명으로 쓴다. */
36
40
  printFileName?: string;
37
41
  /** 표 위에 '표 설정'(구성·용지 방향·외곽선 등) FilterPanel을 노출한다. */
@@ -58,4 +62,4 @@ export interface WordTablePopupProps {
58
62
  * 표 내부에는 제목을 두지 않아 팝업 헤더 제목과 중복되지 않는다.
59
63
  * @public
60
64
  */
61
- export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): import("react").JSX.Element;
65
+ export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, footerLayout, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): import("react").JSX.Element;
@@ -19,7 +19,7 @@ import { useToastState } from '../../hooks/useToastState';
19
19
  * 표 내부에는 제목을 두지 않아 팝업 헤더 제목과 중복되지 않는다.
20
20
  * @public
21
21
  */
22
- export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, printFileName, showSettings, autoPrint, className, onClose, scoring = false, onScoreChange, printHeader, beforeClose }) {
22
+ export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, footerLayout, printFileName, showSettings, autoPrint, className, onClose, scoring = false, onScoreChange, printHeader, beforeClose }) {
23
23
  const { t } = useTranslation();
24
24
  // items 우선, 없으면 문자열 words → 한 단어=한 칸으로 매핑
25
25
  const tableItems = useMemo(() => {
@@ -115,5 +115,5 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
115
115
  }, 100);
116
116
  return () => window.clearTimeout(id);
117
117
  }, [autoPrint]);
118
- return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
118
+ return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, footerLayout: footerLayout, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
119
119
  }
@@ -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.179.0";
49
+ export declare const CORE_VERSION = "0.181.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.179.0';
49
+ export const CORE_VERSION = '0.181.0';