@ihabdevteam/core 0.197.0 → 0.199.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.
@@ -1626,7 +1626,7 @@ export declare function copyText(text: string): Promise<boolean>;
1626
1626
  *
1627
1627
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1628
1628
  */
1629
- export declare const CORE_VERSION = "0.197.0";
1629
+ export declare const CORE_VERSION = "0.199.0";
1630
1630
 
1631
1631
  /** @public */
1632
1632
  declare const coreDefault: Record<string, unknown>;
@@ -7816,6 +7816,18 @@ export declare function toUserErrorMessage(msg: string): string;
7816
7816
  */
7817
7817
  export declare function toWordAttributeFilterCodes(selected: Record<string, unknown> | null | undefined): WordAttributeFilterCodes;
7818
7818
 
7819
+ /**
7820
+ * 표의 한 줄을 `WordEntry` 로 옮긴다.
7821
+ *
7822
+ * 두 가지를 지킨다(두 앱이 지키던 그대로):
7823
+ * - **`CP` 가 없으면 `undefined`** 로 둔다. `null` 을 그대로 넘기면 "구어체 발음이 있는데 비었다"
7824
+ * 와 "아예 없다" 가 구별되지 않는다.
7825
+ * - **`Grade` 는 글로, `Homonum` 은 수로.** 표가 둘 다 수·글 어느 쪽으로도 줄 수 있어
7826
+ * 받는 쪽에서 견줄 때 꼴이 갈린다.
7827
+ * @public
7828
+ */
7829
+ export declare const toWordEntry: (row: WordRow) => WordEntry;
7830
+
7819
7831
  /** 타입 칩 호버 → TrainingTooltip 표시까지의 지연(ms). 말귀자료실 카드와
7820
7832
  * 훈련설정 페이지가 동일한 호버 프리뷰 동작을 각자 구현하고 있어 공유한다.
7821
7833
  * @public */
@@ -9893,6 +9905,32 @@ export declare interface WordPoolEntry {
9893
9905
  grade?: string;
9894
9906
  }
9895
9907
 
9908
+ /**
9909
+ * **낱말 표(`Word`)의 한 줄** — 서버가 주는 그대로의 꼴.
9910
+ *
9911
+ * 열 이름이 짧고 뜻이 안 드러난다(`SP`·`CP`·`Class`). 그래서 받는 쪽마다 "이게 뭐였지" 를
9912
+ * 다시 풀어야 했고, 두 앱이 같은 풀이를 한 벌씩 들고 있었다(회원앱 `useWordData`,
9913
+ * 어드민 `useAllWords` — 글자까지 같은 줄이 열여덟, 2026-09-25).
9914
+ *
9915
+ * **결과 꼴(`WordEntry`)은 이미 코어 것**이었다. 코어가 *무엇이 되는지*만 정하고
9916
+ * *어떻게 그 꼴이 되는지*는 두 앱이 따로 정하고 있던 셈이다 — 열 이름이 바뀌거나 새 열이
9917
+ * 붙었을 때 한쪽만 고치면 **같은 낱말이 회원앱과 어드민에서 다른 발음·다른 급수로 보인다.**
9918
+ * 그런데 아무 시험도 안 넘어진다. 그래서 여기로 옮긴다.
9919
+ *
9920
+ * 받아 오는 길(쪽 나눔·캐시·진행률)은 앱마다 달라 **그대로 앱에 둔다** — 여기 있는 것은
9921
+ * 한 줄을 옮기는 일뿐이다.
9922
+ * @public
9923
+ */
9924
+ export declare interface WordRow {
9925
+ Grade: string | number;
9926
+ Word: string;
9927
+ POS: string;
9928
+ Class: string;
9929
+ SP: string;
9930
+ CP?: string | null;
9931
+ Homonum?: number | null;
9932
+ }
9933
+
9896
9934
  /**
9897
9935
  * 단어를 어떤 조건으로 좁힐지 고르는 FilterPanel 셸(shell) — 등급/품사/어종/음절 수/선택 섹션 자체는
9898
9936
  * 호출부가 구성해 넘긴다. WordSearchPage의 [단어 검색 설정] 패널과 WordTrainingFilterPanels의
@@ -6,7 +6,11 @@
6
6
  bottom: 0;
7
7
  display: flex;
8
8
  flex-direction: column;
9
- box-sizing: content-box;
9
+ /* **전역 `border-box` 를 따른다.** 예전에는 여기만 `content-box` 였다 — 첫 복사 때부터 있었고
10
+ 까닭이 적혀 있지 않다. 그러면 `width: 220px` 에 좌우 테두리 1px 이 **더해져** 실제로는 222px
11
+ 로 서고, 안쪽 내용도 그만큼 어긋난다. 220 폭 상자에 넣으면 오른쪽 2px 이 잘려 나가
12
+ **항목이 좌 14 / 우 10 으로 기울었다**(놀이터 실측 2026-09-25 — 사장님이 "좌우가 달라 보인다"
13
+ 고 보신 것이 이것이다). 적어 둔 폭이 곧 실제 폭이어야 안쪽 셈도 맞는다. */
10
14
  background: var(--color-deepWhite);
11
15
  border-left: 1px solid var(--color-whiteLine);
12
16
  border-right: 1px solid var(--color-whiteLine);
@@ -12,11 +12,51 @@
12
12
  오버레이 스크롤바(마우스 없음·"스크롤할 때만 보기")는 gutter 가 **0** 이라
13
13
  오른쪽이 테두리 1px 만 남았다(실측: 활성 항목 좌 12 / 우 1).
14
14
 
15
- 12px 이던 것을 **8px 으로 낮춘다**(사장님 지시 2026-09-25). 수 대신 토큰을 쓰므로
16
- 여백이 움직일 때 한 자리에서 움직인다 — `--spacing-xs` 는 토큰 파일에서 한 번만
17
- 정해지고 끊는 자리마다 다시 정하는 곳이 없다(`--spacing-content`·`--spacing-page` 와
18
- 다른 점이다). */
19
- padding: 0 var(--spacing-xs);
15
+ **오른쪽은 스크롤바가 자리를 메운다.** 보이는 틈을 좌우 12px 로 맞추되, 오른쪽은 그 12 를
16
+ `패딩 1 + 막대 11` 로 채운다(사장님 지시 2026-09-25). 그래서 좌우 패딩이 다르다 — 숫자가
17
+ 달라 보여도 **눈에 보이는 틈은 같다.**
18
+
19
+ 막대가 11px 인 것은 고른 값이 아니라 **브라우저가 정하는 값**이다. 실측(Chrome 152,
20
+ 2026-09-25): `scrollbar-width` 가 thin 이면 11 · auto 15 · none 0 이고,
21
+ `::-webkit-scrollbar { width }` 는 **있든 없든 값이 같다** — 이 저장소가 스크롤바 폭을 적어 둔
22
+ 자리들(`styles.css` 6px, 훈련 팝업 4px 등)은 **이 브라우저에서는** 아무 일도 안 한다.
23
+
24
+ **두 가지를 확인하지 못했다.** ① 왜 안 먹는지 — 처음에는 "표준 속성이 걸려 있어 Chrome 이
25
+ webkit 쪽을 안 본다" 고 적었는데, 그 규칙을 못 찾은 페이지에서 재도 11 이었다는 실측이 있다.
26
+ ② **사파리는 `::-webkit-scrollbar` 를 존중한다** — 여기서 잴 수단이 없어 못 봤다. 그러니
27
+ 그 규칙들을 "죽은 줄" 로 단정해 지우면 안 된다(말귀가 같은 까닭으로 제 쪽 4px 을 안 걷었다).
28
+ 아래에서 막대를 직접 그리는 쪽으로 간 것은 그래서 더 낫다 — 브라우저마다 다른 이 자리를
29
+ 아예 안 밟는다.
30
+ 그러니 이 11 은 기계·설정에 따라 달라질 수 있는 수다. macOS 오버레이 스크롤바에서는 0 이라
31
+ 오른쪽이 1px 만 남는다 — 그 환경에서 좌우가 어긋나 보이면 그때 다시 정하면 된다.
32
+
33
+ `stable` 은 **오른쪽에만** 자리를 비운다. 스크롤이 생겼다 없어져도 메뉴가 밀리지 않는다. */
34
+ /* **선택·호버 단추가 가운데 서야 한다** — 그래서 좌우를 같은 패딩으로 만든다.
35
+ 테두리 1px 이 양쪽에 있으므로 그만큼 빼, 보이는 틈이 좌우 **12px** 이 된다.
36
+
37
+ **스크롤바 자리는 예약하지 않는다**(`scrollbar-gutter` 를 안 쓴다). 예약하면 그 11px 이
38
+ 오른쪽에만 붙어 단추가 그만큼 왼쪽으로 밀린다 — 게다가 그 자리는 환경마다 값이 달라
39
+ (클래식 11 · macOS 오버레이 0) 어떤 화면에서는 맞고 어떤 화면에서는 어긋난다.
40
+ 막대가 거의 안 보이는 자리에 쓰는 냅이라, 그 자리를 여백으로 돌려 쓰는 편이 낫다
41
+ (사장님 지시 2026-09-25).
42
+
43
+ **막대도 안 그린다**(`scrollbar-width: none`). 안 그러면 넘치는 순간 막대가 11px 을 가져가
44
+ 오른쪽만 23 이 되어(실측) 단추가 다시 왼쪽으로 밀린다 — 자리를 예약하든(늘 밀림) 안 하든
45
+ (넘칠 때만 밀림) 막대가 보이는 한 가운데는 지켜지지 않는다. 굴리기·터치·키보드 스크롤은
46
+ 그대로다. 접힌 72px 레일이 같은 까닭으로 이미 그렇게 하고 있다(Header.css).
47
+
48
+ 잃는 것은 **"더 있다" 는 신호**다. 메뉴가 길어 잘릴 때 막대가 알려 주던 것이 없어진다 —
49
+ 항목이 화면을 넘는 일이 드문 자리라 가운데 맞춤을 택했다(사장님 지시 2026-09-25). */
50
+ padding-left: calc(var(--spacing-s) - 1px);
51
+ padding-right: calc(var(--spacing-s) - 1px);
52
+ scrollbar-width: none;
53
+ /* **막대가 끝나는 자리를 내용에 맞춘다.** 스크롤바는 이 통의 위아래 끝까지 그려지는데,
54
+ 발치가 제 안쪽에 아래 여백 12px 을 두고 있어 **내용은 12 위에서 끝나고 막대만 바닥까지**
55
+ 내려갔다(사장님 지적 2026-09-25). 그 12 를 통 **바깥**(margin)으로 옮기면 통 자체가 12 위에서
56
+ 끝나므로 막대도 거기서 끝난다 — 보이는 여백은 그대로다.
57
+ 위도 같다: 목록이 위 여백 4px 을 안에 두고 있어 막대가 첫 단추보다 4px 위에서 시작했다. */
58
+ margin-top: 4px;
59
+ margin-bottom: var(--spacing-s);
20
60
  /* 스크롤바는 padding 바깥·border 안쪽에 그려진다 — 투명 테두리를 두면 그만큼
21
61
  안쪽으로 들어와, 콘텐츠 영역과 맞닿은 경계선에 딱 붙지 않는다. 왼쪽에도 같은
22
62
  폭을 둬 짝을 맞춘다. */
@@ -25,30 +65,62 @@
25
65
  background-clip: padding-box;
26
66
  overflow-y: auto; /* 항상 단일 스크롤 컨테이너 */
27
67
  overflow-x: hidden;
28
- /* 스크롤바 자리를 늘 비워둔다 — 메뉴 수나 창 높이에 따라 스크롤이 생겼다 없어지면
29
- 그때마다 메뉴가 좌우로 밀려 흔들린다. **양쪽에 똑같이** 비워, 자리가 0 인
30
- 환경(오버레이)이든 몇 px 인 환경(클래식)이든 좌우가 같게 한다.
31
- 실측(1400px, 스크롤이 서든 안 서든 같다): 오버레이 좌 9 / 우 9,
32
- 클래식 11px 좌 20 / 우 20. 클래식에서는 항목 폭이 그만큼(196 → 180) 줄지만
33
- 글자가 들어가기에 넉넉하다.
34
- 접힌 72px 레일은 그 폭을 못 버틴다 — Header.css 에서 따로 다룬다. */
35
- scrollbar-gutter: stable both-edges;
36
68
  overscroll-behavior: contain;
37
69
  -webkit-overflow-scrolling: touch;
38
- scrollbar-width: thin;
39
- /* 트랙은 투명 — 사이드바 배경이 모드마다 다르다(말귀는 단색, 매니저는 그라디언트).
40
- 특정 색으로 고정하면 매니저 모드에서 흰 세로줄로 튄다. thumb만 색을 준다. */
41
- scrollbar-color: var(--color-darkWhite) transparent;
42
70
  }
43
71
 
44
- .side-nav__menu::-webkit-scrollbar { width: 4px; }
45
- .side-nav__menu::-webkit-scrollbar-track { background: transparent; }
46
- .side-nav__menu::-webkit-scrollbar-thumb {
72
+ /* 표준 속성을 모르는 옛 WebKit 몫 — 접힌 레일과 같은 처리 */
73
+ .side-nav__menu::-webkit-scrollbar { display: none; }
74
+
75
+ /* ── 떠 있는 스크롤 막대 ───────────────────────────────────
76
+ 네이티브 막대는 **폭을 정할 수 없고**(전역 `scrollbar-width: thin` 이 이기므로 4px 을 적어도
77
+ 11px 로 선다) **자리를 먹어** 항목을 왼쪽으로 민다(실측: 오른쪽만 23px). 그래서 직접 그린다 —
78
+ 떠 있으니 자리를 안 먹고, 항목은 좌우 12px 그대로 가운데에 선다.
79
+
80
+ `sticky` 한 겹이 구르는 통 안에서 제자리를 지키고(높이 0 이라 흐름을 안 밀어낸다), 그 안의
81
+ 막대가 `top` 만 옮겨 다닌다. 굴리는 일은 통이 그대로 한다 — 이것은 어디쯤인지 알리는 그림이다. */
82
+ .side-nav__scroll {
83
+ position: sticky;
84
+ top: 0;
85
+ height: 0;
86
+ overflow: visible;
87
+ z-index: 1;
88
+ /* 이 겹은 통 전체를 덮으므로 **손짓을 가로채면 안 된다** — 막대만 도로 받는다 */
89
+ pointer-events: none;
90
+ /* **통의 안쪽 여백 바깥까지 편다.** 안 펴면 이 겹이 내용 폭에서 끝나, 막대가 항목 위에
91
+ 얹힌다. 여백만큼 음수로 밀어 두면 막대를 그 **여백 안**에 앉힐 수 있다. */
92
+ margin-left: calc(-1 * (var(--spacing-s) - 1px));
93
+ margin-right: calc(-1 * (var(--spacing-s) - 1px));
94
+ }
95
+ .side-nav__scroll-thumb {
96
+ position: absolute;
97
+ /* **오른쪽 여백 한가운데.** 여백 11 에서 막대 폭을 뺀 나머지를 반씩 나눈다 —
98
+ 항목도 안 덮고 테두리에도 안 닿는다(사장님 지시 2026-09-25 "12 패딩 사이에 딱").
99
+ 폭 6 은 전역 CSS 가 `::-webkit-scrollbar` 에 적어 두고도 못 지키던 값과 같다
100
+ (4 는 너무 얇았다 — 사장님 지시). */
101
+ /* 셈으로는 여백 한가운데인데 **눈에는 1px 왼쪽으로 보인다**(사장님 지적 2026-09-25) — 그럴
102
+ 만하다: 이 여백 오른쪽에는 통의 투명 테두리와 헤더의 테두리가 더 붙어 있어, 보이는 띠는
103
+ 셈한 11px 보다 넓다. 그만큼 오른쪽으로 민다. */
104
+ right: calc((var(--spacing-s) - 1px - 6px) / 2 - 1px);
105
+ width: 6px;
106
+ border-radius: 3px;
47
107
  background: var(--color-darkWhite);
48
- border-radius: var(--radius-xs);
49
- }
50
- .side-nav__menu::-webkit-scrollbar-thumb:hover {
51
- background: var(--color-lightGray, var(--color-lightGray));
108
+ /* **잡히는 자리다.** 손짓을 도로 받아 얹으면 짙어지고, 끌면 통이 따라 구른다.
109
+ 손가락으로 겨누기엔 6px 이 좁아 위아래로만 두고, 좌우는 보이지 않는 여백을 덧대
110
+ (`::before`) 겨누는 자리를 넓힌다 — 보이는 굵기는 그대로다. */
111
+ pointer-events: auto;
112
+ touch-action: none;
113
+ transition: background-color 160ms ease, opacity 180ms ease;
114
+ }
115
+ .side-nav__scroll-thumb::before {
116
+ content: '';
117
+ position: absolute;
118
+ /* 여백(11) 안에서 좌우로 더 집는다 — 보이는 막대는 6px 그대로 */
119
+ inset: 0 -4px;
120
+ }
121
+ .side-nav__scroll-thumb:hover,
122
+ .side-nav__scroll-thumb:active {
123
+ background: var(--color-lightGray);
52
124
  }
53
125
 
54
126
  /* ── list: 자체 스크롤 없음 → side-nav__menu 단일 스크롤에 위임 ──────────────── */
@@ -61,8 +133,10 @@
61
133
  overflow: visible;
62
134
  /* 예전엔 padding: 4px 16px + margin: 0 -16px 였는데 좌우가 정확히 상쇄돼
63
135
  항목 위치에는 아무 영향이 없고, 폭만 228px로 부풀어 부모(220px)를 넘었다
64
- (overflow-x: hidden에 잘려 보이지 않았을 뿐). 세로 여백만 남긴다. */
65
- padding: 4px 0;
136
+ (overflow-x: hidden에 잘려 보이지 않았을 뿐).
137
+ **위 여백 4px 은 통 바깥(margin-top)으로 옮겼다** — 안에 두면 막대가 첫 단추보다 그만큼
138
+ 위에서 시작한다(2026-09-25). 아래 4px 은 발치와의 사이라 그대로 둔다. */
139
+ padding: 0 0 4px;
66
140
  }
67
141
 
68
142
  /* ── footer: 항상 list 아래에 위치, 줄어들지 않음 ─────────────────────────── */
@@ -76,7 +150,9 @@
76
150
  flex-shrink: 0;
77
151
  /* 위아래 여백을 좌우와 같은 12px로 — 좌측 padding, 우측 'gutter + 테두리'와 맞춘다. */
78
152
  margin-top: var(--spacing-s);
79
- padding: 0 0 var(--spacing-s) 0;
153
+ /* 아래 여백은 통 바깥(`.side-nav__menu` 의 margin-bottom)으로 옮겼다 — 막대가 내용과 같은
154
+ 자리에서 끝나게 하려는 것이고, 보이는 여백은 그대로 12px 이다. */
155
+ padding: 0;
80
156
  display: flex;
81
157
  flex-direction: column;
82
158
  gap: var(--spacing-s);
@@ -4,7 +4,7 @@ import './SideNav.css';
4
4
  import Icon from './Icon';
5
5
  import Button from './Button';
6
6
  import Badge from './Badge';
7
- import React, { useCallback } from 'react';
7
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
9
  import { isSafeOpenHref } from '../utils/safeHref';
10
10
  import { scrollNavTargetToTop } from '../utils/navScroll';
@@ -63,7 +63,59 @@ export default function SideNav(props) {
63
63
  데스크톱은 '말귀로 이동', 모바일은 '말귀 매니저' 로 서로 반대를 가리켰다. */
64
64
  const isManager = appMode === 'manager';
65
65
  const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
66
- return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
66
+ /* **떠 있는 막대의 자리와 길이** — 구르는 통(이 `<nav>`)을 재어 셈한다. 네이티브 막대는
67
+ 폭을 정할 수 없고 자리를 먹어 항목을 밀어내므로(2026-09-25 실측: 오른쪽만 23px) 직접 그린다. */
68
+ const 통ref = useRef(null);
69
+ const [막대, set막대] = useState(null);
70
+ useEffect(() => {
71
+ const el = 통ref.current;
72
+ if (!el)
73
+ return;
74
+ const 재기 = () => {
75
+ const { scrollHeight, clientHeight, scrollTop } = el;
76
+ /* 넘치지 않으면 그리지 않는다 — 없는 스크롤을 알릴 까닭이 없다 */
77
+ if (scrollHeight <= clientHeight + 1) {
78
+ set막대(null);
79
+ return;
80
+ }
81
+ const 길이 = Math.max(24, (clientHeight / scrollHeight) * clientHeight);
82
+ const 남은길 = clientHeight - 길이;
83
+ const 남은굴림 = scrollHeight - clientHeight;
84
+ set막대({ top: Math.round((scrollTop / 남은굴림) * 남은길), height: Math.round(길이) });
85
+ };
86
+ 재기();
87
+ el.addEventListener('scroll', 재기, { passive: true });
88
+ /* 항목이 늘거나 창이 바뀌면 길이도 달라진다 */
89
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(재기) : null;
90
+ ro?.observe(el);
91
+ for (const c of Array.from(el.children))
92
+ ro?.observe(c);
93
+ return () => { el.removeEventListener('scroll', 재기); ro?.disconnect(); };
94
+ }, [items]);
95
+ /* **막대를 잡아 끌 수 있다.** 손에 잡히게 그려 놓고 안 잡히면 그 꼴이 거짓말이 된다.
96
+ 통의 한 픽셀은 막대의 `scrollHeight / clientHeight` 배다 — 그 비로 옮긴다. */
97
+ const 끌기 = useRef(null);
98
+ const 막대잡기 = useCallback((e) => {
99
+ const el = 통ref.current;
100
+ if (!el)
101
+ return;
102
+ e.preventDefault();
103
+ 끌기.current = { y: e.clientY, top: el.scrollTop };
104
+ e.currentTarget.setPointerCapture(e.pointerId);
105
+ }, []);
106
+ const 막대끌기 = useCallback((e) => {
107
+ const el = 통ref.current;
108
+ const 잡은것 = 끌기.current;
109
+ if (!el || !잡은것)
110
+ return;
111
+ el.scrollTop = 잡은것.top + (e.clientY - 잡은것.y) * (el.scrollHeight / el.clientHeight);
112
+ }, []);
113
+ const 막대놓기 = useCallback((e) => {
114
+ 끌기.current = null;
115
+ if (e.currentTarget.hasPointerCapture(e.pointerId))
116
+ e.currentTarget.releasePointerCapture(e.pointerId);
117
+ }, []);
118
+ return (_jsxs("nav", { ref: 통ref, "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [막대 && (_jsx("div", { className: "side-nav__scroll", "aria-hidden": "true", children: _jsx("div", { className: "side-nav__scroll-thumb", style: { top: 막대.top, height: 막대.height }, onPointerDown: 막대잡기, onPointerMove: 막대끌기, onPointerUp: 막대놓기, onPointerCancel: 막대놓기 }) })), _jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
67
119
  .filter((it) => !it.section && (it.key || it.label))
68
120
  .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: !!it.key && it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
69
121
  if (it.disabled)
@@ -0,0 +1,37 @@
1
+ import type { WordEntry } from './phonemePairSearchTypes';
2
+ /**
3
+ * **낱말 표(`Word`)의 한 줄** — 서버가 주는 그대로의 꼴.
4
+ *
5
+ * 열 이름이 짧고 뜻이 안 드러난다(`SP`·`CP`·`Class`). 그래서 받는 쪽마다 "이게 뭐였지" 를
6
+ * 다시 풀어야 했고, 두 앱이 같은 풀이를 한 벌씩 들고 있었다(회원앱 `useWordData`,
7
+ * 어드민 `useAllWords` — 글자까지 같은 줄이 열여덟, 2026-09-25).
8
+ *
9
+ * **결과 꼴(`WordEntry`)은 이미 코어 것**이었다. 코어가 *무엇이 되는지*만 정하고
10
+ * *어떻게 그 꼴이 되는지*는 두 앱이 따로 정하고 있던 셈이다 — 열 이름이 바뀌거나 새 열이
11
+ * 붙었을 때 한쪽만 고치면 **같은 낱말이 회원앱과 어드민에서 다른 발음·다른 급수로 보인다.**
12
+ * 그런데 아무 시험도 안 넘어진다. 그래서 여기로 옮긴다.
13
+ *
14
+ * 받아 오는 길(쪽 나눔·캐시·진행률)은 앱마다 달라 **그대로 앱에 둔다** — 여기 있는 것은
15
+ * 한 줄을 옮기는 일뿐이다.
16
+ * @public
17
+ */
18
+ export interface WordRow {
19
+ Grade: string | number;
20
+ Word: string;
21
+ POS: string;
22
+ Class: string;
23
+ SP: string;
24
+ CP?: string | null;
25
+ Homonum?: number | null;
26
+ }
27
+ /**
28
+ * 표의 한 줄을 `WordEntry` 로 옮긴다.
29
+ *
30
+ * 두 가지를 지킨다(두 앱이 지키던 그대로):
31
+ * - **`CP` 가 없으면 `undefined`** 로 둔다. `null` 을 그대로 넘기면 "구어체 발음이 있는데 비었다"
32
+ * 와 "아예 없다" 가 구별되지 않는다.
33
+ * - **`Grade` 는 글로, `Homonum` 은 수로.** 표가 둘 다 수·글 어느 쪽으로도 줄 수 있어
34
+ * 받는 쪽에서 견줄 때 꼴이 갈린다.
35
+ * @public
36
+ */
37
+ export declare const toWordEntry: (row: WordRow) => WordEntry;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 표의 한 줄을 `WordEntry` 로 옮긴다.
3
+ *
4
+ * 두 가지를 지킨다(두 앱이 지키던 그대로):
5
+ * - **`CP` 가 없으면 `undefined`** 로 둔다. `null` 을 그대로 넘기면 "구어체 발음이 있는데 비었다"
6
+ * 와 "아예 없다" 가 구별되지 않는다.
7
+ * - **`Grade` 는 글로, `Homonum` 은 수로.** 표가 둘 다 수·글 어느 쪽으로도 줄 수 있어
8
+ * 받는 쪽에서 견줄 때 꼴이 갈린다.
9
+ * @public
10
+ */
11
+ export const toWordEntry = (row) => ({
12
+ word: row.Word,
13
+ pronunciation: row.SP,
14
+ colloquialPronunciation: row.CP ?? undefined,
15
+ grade: String(row.Grade),
16
+ pos: row.POS,
17
+ wordType: row.Class,
18
+ homonum: row.Homonum != null ? Number(row.Homonum) : undefined,
19
+ });
@@ -58,4 +58,6 @@ export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGE
58
58
  export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
59
59
  export { lazyPage } from './utils/lazyPage';
60
60
  export { prefetchChunk } from './utils/prefetchChunk';
61
+ export { toWordEntry } from './utils/wordRow';
62
+ export type { WordRow } from './utils/wordRow';
61
63
  export { CORE_VERSION } from './version';
@@ -47,4 +47,5 @@ export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGE
47
47
  export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
48
48
  export { lazyPage } from './utils/lazyPage';
49
49
  export { prefetchChunk } from './utils/prefetchChunk';
50
+ export { toWordEntry } from './utils/wordRow';
50
51
  export { CORE_VERSION } from './version';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.197.0";
49
+ export declare const CORE_VERSION = "0.199.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.197.0';
49
+ export const CORE_VERSION = '0.199.0';