@ihabdevteam/core 0.200.0 → 0.201.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +186 -186
  7. package/dist/ihabdevteam-core.d.ts +206 -18
  8. package/dist/packages/core/src/components/Header.css +139 -118
  9. package/dist/packages/core/src/components/Popup/useDiscardGuard.d.ts +48 -0
  10. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +56 -0
  11. package/dist/packages/core/src/components.d.ts +2 -0
  12. package/dist/packages/core/src/components.js +1 -0
  13. package/dist/packages/core/src/hooks/useListControls.d.ts +66 -0
  14. package/dist/packages/core/src/hooks/useListControls.js +52 -0
  15. package/dist/packages/core/src/hooks.d.ts +1 -0
  16. package/dist/packages/core/src/hooks.js +1 -0
  17. package/dist/packages/core/src/i18n/locales/en.json +5 -0
  18. package/dist/packages/core/src/i18n/locales/ko.json +5 -0
  19. package/dist/packages/core/src/utils/chunkSkew.d.ts +27 -0
  20. package/dist/packages/core/src/utils/chunkSkew.js +44 -0
  21. package/dist/packages/core/src/utils/format.d.ts +14 -14
  22. package/dist/packages/core/src/utils/format.js +27 -1
  23. package/dist/packages/core/src/utils/sessionTab.d.ts +36 -0
  24. package/dist/packages/core/src/utils/sessionTab.js +36 -0
  25. package/dist/packages/core/src/utils.d.ts +4 -2
  26. package/dist/packages/core/src/utils.js +3 -2
  27. package/dist/packages/core/src/version.d.ts +1 -1
  28. package/dist/packages/core/src/version.js +1 -1
  29. package/dist/utils.cjs.js +2 -2
  30. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import type { ReactNode } from 'react';
2
+ /** @public */
3
+ export interface DiscardGuardLabels {
4
+ title?: string;
5
+ message?: string;
6
+ confirmLabel?: string;
7
+ }
8
+ /** @public */
9
+ export interface DiscardGuard {
10
+ /** `Popup` 의 `beforeClose` 로 넘긴다 — `false` 면 안 닫힌다 */
11
+ beforeClose: () => boolean;
12
+ /** 물음창. JSX 어디에 두든 된다(아래 글 참고) */
13
+ discardPopup: ReactNode;
14
+ /** **다음 한 번은 안 묻는다** — 저장이 끝나고 스스로 닫을 때 부른다 */
15
+ allowClose: () => void;
16
+ }
17
+ /**
18
+ * **쓰던 것을 버리고 닫으려 할 때 한 번 더 묻는다.**
19
+ *
20
+ * 코어 0.128.0 부터 창은 어두운 바탕을 눌러도 닫힌다(사장님 지시 2026-09-14 "기본값으로").
21
+ * 긴 글을 받는 창에서 바깥을 한 번 잘못 누르면 쓰던 것이 통째로 사라진다 — 그 자리를 막는다.
22
+ *
23
+ * 말귀 회원앱과 어드민에 한 벌씩 있었고(2026-09-27) **어드민 쪽이 윗집합**이었다. 갈라져
24
+ * 있던 탓에 어드민에서 찾은 고침(`allowClose`)이 회원앱으로 건너오지 못했다 — 회원앱
25
+ * 고객 추가 창이 **저장을 끝내고도 "저장했는데 버릴까요?" 를 물었다.** 여기 한 벌로 둔다.
26
+ *
27
+ * **`allowClose` 가 그 고침이다.** 저장이 끝나고 창이 스스로 닫을 때(`closeWithAnimation`)도
28
+ * 그 길은 `beforeClose` 를 거치는데, 폼은 방금 채워 달라진 채라 "버릴까요" 가 뜬다. 저장에
29
+ * 성공한 직후 `allowClose()` 를 부르면 **다음 한 번만** 안 묻고 닫는다. 같은 틱에 닫으므로
30
+ * 상태가 아니라 ref 로 든다 — 상태로 들면 다시 그려지기 전에 닫혀 안 읽힌다.
31
+ *
32
+ * **물음창은 JSX 어디에 두든 된다.** 창은 `document.body` 로 옮겨 붙고(`createPortal`)
33
+ * 그 차례는 코드 자리가 아니라 **뜬 시각**으로 정해지므로, 나중에 뜨는 이 물음창이 늘 위다.
34
+ *
35
+ * 문구는 `labels` 로 바꾼다 — 안 주면 사전(`common.discardConfirm.*`)을 따르고, 사전이
36
+ * 없으면 한국어 기본값으로 그린다(번역을 안 쓰는 소비자도 그대로 읽힌다).
37
+ *
38
+ * ```tsx
39
+ * const { beforeClose, discardPopup, allowClose } = useDiscardGuard(달라졌나, 닫기)
40
+ * const 저장 = async () => { await 보내기(); allowClose(); closeWithAnimation() }
41
+ * return <Popup title={제목} beforeClose={beforeClose} …>{…}{discardPopup}</Popup>
42
+ * ```
43
+ *
44
+ * @param isDirty 지금 닫으면 잃을 것이 있는가
45
+ * @param onClose 버리기로 했을 때 실제로 닫는 일
46
+ * @public
47
+ */
48
+ export declare function useDiscardGuard(isDirty: boolean, onClose: () => void, labels?: DiscardGuardLabels): DiscardGuard;
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useCallback, useRef, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import ConfirmPopup from './ConfirmPopup';
6
+ /**
7
+ * **쓰던 것을 버리고 닫으려 할 때 한 번 더 묻는다.**
8
+ *
9
+ * 코어 0.128.0 부터 창은 어두운 바탕을 눌러도 닫힌다(사장님 지시 2026-09-14 "기본값으로").
10
+ * 긴 글을 받는 창에서 바깥을 한 번 잘못 누르면 쓰던 것이 통째로 사라진다 — 그 자리를 막는다.
11
+ *
12
+ * 말귀 회원앱과 어드민에 한 벌씩 있었고(2026-09-27) **어드민 쪽이 윗집합**이었다. 갈라져
13
+ * 있던 탓에 어드민에서 찾은 고침(`allowClose`)이 회원앱으로 건너오지 못했다 — 회원앱
14
+ * 고객 추가 창이 **저장을 끝내고도 "저장했는데 버릴까요?" 를 물었다.** 여기 한 벌로 둔다.
15
+ *
16
+ * **`allowClose` 가 그 고침이다.** 저장이 끝나고 창이 스스로 닫을 때(`closeWithAnimation`)도
17
+ * 그 길은 `beforeClose` 를 거치는데, 폼은 방금 채워 달라진 채라 "버릴까요" 가 뜬다. 저장에
18
+ * 성공한 직후 `allowClose()` 를 부르면 **다음 한 번만** 안 묻고 닫는다. 같은 틱에 닫으므로
19
+ * 상태가 아니라 ref 로 든다 — 상태로 들면 다시 그려지기 전에 닫혀 안 읽힌다.
20
+ *
21
+ * **물음창은 JSX 어디에 두든 된다.** 창은 `document.body` 로 옮겨 붙고(`createPortal`)
22
+ * 그 차례는 코드 자리가 아니라 **뜬 시각**으로 정해지므로, 나중에 뜨는 이 물음창이 늘 위다.
23
+ *
24
+ * 문구는 `labels` 로 바꾼다 — 안 주면 사전(`common.discardConfirm.*`)을 따르고, 사전이
25
+ * 없으면 한국어 기본값으로 그린다(번역을 안 쓰는 소비자도 그대로 읽힌다).
26
+ *
27
+ * ```tsx
28
+ * const { beforeClose, discardPopup, allowClose } = useDiscardGuard(달라졌나, 닫기)
29
+ * const 저장 = async () => { await 보내기(); allowClose(); closeWithAnimation() }
30
+ * return <Popup title={제목} beforeClose={beforeClose} …>{…}{discardPopup}</Popup>
31
+ * ```
32
+ *
33
+ * @param isDirty 지금 닫으면 잃을 것이 있는가
34
+ * @param onClose 버리기로 했을 때 실제로 닫는 일
35
+ * @public
36
+ */
37
+ export function useDiscardGuard(isDirty, onClose, labels = {}) {
38
+ const { t } = useTranslation();
39
+ const [confirming, setConfirming] = useState(false);
40
+ const 통과 = useRef(false);
41
+ const allowClose = useCallback(() => { 통과.current = true; }, []);
42
+ const beforeClose = useCallback(() => {
43
+ /* 한 번 쓰고 되돌린다 — 안 되돌리면 그 뒤로 영영 안 묻는다 */
44
+ if (통과.current) {
45
+ 통과.current = false;
46
+ return true;
47
+ }
48
+ if (isDirty) {
49
+ setConfirming(true);
50
+ return false;
51
+ }
52
+ return true;
53
+ }, [isDirty]);
54
+ const discardPopup = confirming ? (_jsx(ConfirmPopup, { title: labels.title ?? t('common.discardConfirm.title', '입력을 취소할까요?'), message: labels.message ?? t('common.discardConfirm.body', '저장하지 않으면 지금까지 입력한 내용이 사라집니다.'), confirmLabel: labels.confirmLabel ?? t('common.discardConfirm.confirm', '입력 취소'), confirmVariant: "danger", onConfirm: () => { setConfirming(false); onClose(); }, onClose: () => setConfirming(false) })) : null;
55
+ return { beforeClose, discardPopup, allowClose };
56
+ }
@@ -238,6 +238,8 @@ export { default as ListPopup } from './components/Popup/ListPopup';
238
238
  export type { PopupListProps, PopupListColumn } from './components/Popup/ListPopup';
239
239
  export { default as ConfirmPopup } from './components/Popup/ConfirmPopup';
240
240
  export type { ConfirmPopupProps } from './components/Popup/ConfirmPopup';
241
+ export { useDiscardGuard } from './components/Popup/useDiscardGuard';
242
+ export type { DiscardGuard, DiscardGuardLabels } from './components/Popup/useDiscardGuard';
241
243
  export { default as CouponInputPopup } from './components/Popup/CouponInputPopup';
242
244
  export type { CouponInputPopupProps } from './components/Popup/CouponInputPopup';
243
245
  export { default as MenuTooltip } from './components/Tooltip/MenuTooltip';
@@ -170,6 +170,7 @@ export { default as TableFilterPanel } from './components/Word/TableFilterPanel'
170
170
  export { default as ContrastFilterPanel } from './components/Word/ContrastFilterPanel';
171
171
  export { default as ListPopup } from './components/Popup/ListPopup';
172
172
  export { default as ConfirmPopup } from './components/Popup/ConfirmPopup';
173
+ export { useDiscardGuard } from './components/Popup/useDiscardGuard';
173
174
  export { default as CouponInputPopup } from './components/Popup/CouponInputPopup';
174
175
  export { default as MenuTooltip } from './components/Tooltip/MenuTooltip';
175
176
  export { default as InfoTooltip } from './components/Tooltip/InfoTooltip';
@@ -0,0 +1,66 @@
1
+ /** 첫 쪽으로 돌아갈 까닭을 잇는 값 — **글로 적을 수 있는 것**만 받는다(아래 `resetOn` 참고) @public */
2
+ export type ListResetValue = string | number | boolean | null | undefined;
3
+ /** @public */
4
+ export interface ListControlsOptions {
5
+ /**
6
+ * 쪽크기 기본값. 글인 것은 고르개(`Dropdown`)가 글을 다루기 때문이다 — 숫자로 바꾸는 일은
7
+ * 이 훅이 한다(`perPage`). 안 주면 `'25'`.
8
+ */
9
+ defaultPageSize?: string;
10
+ /**
11
+ * 검색어·쪽크기 **말고 더** 바뀌면 첫 쪽으로 돌아가야 하는 것들 — 갈래(`type`) · 보기(`view`) 처럼
12
+ * 목록을 거르는 값이다. 검색어와 쪽크기는 이 훅이 스스로 넣으므로 **여기 또 적지 않는다.**
13
+ *
14
+ * 값은 글·수·참거짓만 받는다. 객체를 넣으면 `String()` 이 전부 `[object Object]` 로 만들어
15
+ * **무엇이 바뀌어도 안 바뀐 것이 되고, 그래도 아무 데서도 안 터진다** — 타입으로 막는다.
16
+ */
17
+ resetOn?: readonly ListResetValue[];
18
+ }
19
+ /** @public */
20
+ export interface ListControls {
21
+ /** 찾는 말. 무엇을 어떻게 거를지는 **부르는 쪽**이 정한다 — 이 훅은 그 말만 쥔다 */
22
+ query: string;
23
+ setQuery: (v: string) => void;
24
+ /** 고르개에 그대로 주는 글 */
25
+ pageSize: string;
26
+ setPageSize: (v: string) => void;
27
+ /** `usePagedList` 에 그대로 주는 수 — `Number(pageSize)` 가 못 읽히면 기본값이다 */
28
+ perPage: number;
29
+ page: number;
30
+ setPage: (n: number) => void;
31
+ }
32
+ /**
33
+ * **목록 화면의 조작부 한 벌** — 찾는 말 · 쪽크기 · 쪽 번호, 그리고 **언제 첫 쪽으로 돌아가는가.**
34
+ *
35
+ * 화면 셋이 같은 다섯 줄을 손으로 엮고 있었다(어드민 문장 자료 · 어드민 낱말 자료 · 회원앱 낱말
36
+ * 관리 — 2026-09-27). `usePagedList` 를 코어로 올린 까닭이 한 겹 위에서 되풀이된 자리다.
37
+ *
38
+ * **이 훅이 막는 것은 조용히 틀어지는 쪽이다.** `usePageResetOnChange` 의 열쇠에서 한 조각만
39
+ * 빠뜨리면 — 이를테면 쪽크기 — 25에서 100으로 바꿔도 `page` 가 7에 머문다. 총 쪽수가 2로 줄어
40
+ * `usePagedList` 가 마지막 쪽으로 조이니 **2쪽이 뜨는데 사람은 1쪽을 본다고 믿는다.** 오류도
41
+ * 빈 화면도 아니고 **엉뚱한 쪽**이라 아무도 못 알아챈다. 그래서 검색어와 쪽크기는 부르는 쪽이
42
+ * 적는 것이 아니라 **이 훅이 스스로 넣는다.** 적지 않은 것은 빠뜨릴 수도 없다.
43
+ *
44
+ * 쪽크기 기본값(`'25'`)도 여기가 쥔다 — 세 화면에 `Number(pageSize) || 25` 가 한 벌씩 있었다.
45
+ * (코어 `Pagination` 에는 쪽크기 고르개가 없어 맞춰야 할 기본값이 따로 있지는 않다. 겹친 것은
46
+ * 화면 셋의 `25` 뿐이다.)
47
+ *
48
+ * **거르고 세우는 일은 안 맡는다.** 세 화면의 잣대가 서로 다르고(`묶음에서찾기` ·
49
+ * `searchAndRankWords` · 보기별 거르기 + 낱말 순위), 거르기는 화면이 아는 일이다. 무엇보다
50
+ * 그 일을 이 훅 안에 넣으면 **부르는 쪽의 `useMemo` 가 사라진다** — 낱말 자료 화면은 23,000개를
51
+ * 세우므로 매 그림마다 다시 세우게 된다.
52
+ *
53
+ * 그래서 두 걸음이다. 거르는 일이 가운데 있고, 그 자리가 곧 `useMemo` 가 설 자리다:
54
+ *
55
+ * ```tsx
56
+ * const 목록 = useListControls({ resetOn: [보기] })
57
+ * const 걸러낸것 = useMemo(() => 거르기(줄들, 목록.query, 보기), [줄들, 목록.query, 보기])
58
+ * const { pageItems, totalPages, curPage } = usePagedList(걸러낸것, 목록.perPage, 목록.page)
59
+ * ```
60
+ *
61
+ * 찾는 말을 이 훅이 쥐는 것은 **첫 쪽으로 돌아갈 까닭**이기 때문이다. 쥐고 있으니 열쇠에 넣는
62
+ * 일도 여기서 끝난다 — `목록.query` 는 위처럼 거를 때 그대로 쓴다.
63
+ *
64
+ * @public
65
+ */
66
+ export declare function useListControls(opts?: ListControlsOptions): ListControls;
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+ import { useState } from 'react';
3
+ import { usePageResetOnChange } from './usePageResetOnChange';
4
+ /**
5
+ * **목록 화면의 조작부 한 벌** — 찾는 말 · 쪽크기 · 쪽 번호, 그리고 **언제 첫 쪽으로 돌아가는가.**
6
+ *
7
+ * 화면 셋이 같은 다섯 줄을 손으로 엮고 있었다(어드민 문장 자료 · 어드민 낱말 자료 · 회원앱 낱말
8
+ * 관리 — 2026-09-27). `usePagedList` 를 코어로 올린 까닭이 한 겹 위에서 되풀이된 자리다.
9
+ *
10
+ * **이 훅이 막는 것은 조용히 틀어지는 쪽이다.** `usePageResetOnChange` 의 열쇠에서 한 조각만
11
+ * 빠뜨리면 — 이를테면 쪽크기 — 25에서 100으로 바꿔도 `page` 가 7에 머문다. 총 쪽수가 2로 줄어
12
+ * `usePagedList` 가 마지막 쪽으로 조이니 **2쪽이 뜨는데 사람은 1쪽을 본다고 믿는다.** 오류도
13
+ * 빈 화면도 아니고 **엉뚱한 쪽**이라 아무도 못 알아챈다. 그래서 검색어와 쪽크기는 부르는 쪽이
14
+ * 적는 것이 아니라 **이 훅이 스스로 넣는다.** 적지 않은 것은 빠뜨릴 수도 없다.
15
+ *
16
+ * 쪽크기 기본값(`'25'`)도 여기가 쥔다 — 세 화면에 `Number(pageSize) || 25` 가 한 벌씩 있었다.
17
+ * (코어 `Pagination` 에는 쪽크기 고르개가 없어 맞춰야 할 기본값이 따로 있지는 않다. 겹친 것은
18
+ * 화면 셋의 `25` 뿐이다.)
19
+ *
20
+ * **거르고 세우는 일은 안 맡는다.** 세 화면의 잣대가 서로 다르고(`묶음에서찾기` ·
21
+ * `searchAndRankWords` · 보기별 거르기 + 낱말 순위), 거르기는 화면이 아는 일이다. 무엇보다
22
+ * 그 일을 이 훅 안에 넣으면 **부르는 쪽의 `useMemo` 가 사라진다** — 낱말 자료 화면은 23,000개를
23
+ * 세우므로 매 그림마다 다시 세우게 된다.
24
+ *
25
+ * 그래서 두 걸음이다. 거르는 일이 가운데 있고, 그 자리가 곧 `useMemo` 가 설 자리다:
26
+ *
27
+ * ```tsx
28
+ * const 목록 = useListControls({ resetOn: [보기] })
29
+ * const 걸러낸것 = useMemo(() => 거르기(줄들, 목록.query, 보기), [줄들, 목록.query, 보기])
30
+ * const { pageItems, totalPages, curPage } = usePagedList(걸러낸것, 목록.perPage, 목록.page)
31
+ * ```
32
+ *
33
+ * 찾는 말을 이 훅이 쥐는 것은 **첫 쪽으로 돌아갈 까닭**이기 때문이다. 쥐고 있으니 열쇠에 넣는
34
+ * 일도 여기서 끝난다 — `목록.query` 는 위처럼 거를 때 그대로 쓴다.
35
+ *
36
+ * @public
37
+ */
38
+ export function useListControls(opts = {}) {
39
+ const { defaultPageSize = '25', resetOn } = opts;
40
+ const [query, setQuery] = useState('');
41
+ const [pageSize, setPageSize] = useState(defaultPageSize);
42
+ const [page, setPage] = useState(1);
43
+ /* 못 읽히는 쪽크기('' · 'all' · '0')는 기본값으로 — 그 셈이 화면마다 한 벌씩 있었다.
44
+ 기본값 자체가 못 읽히면 25 다. 목록이 통째로 사라지는 값(0·NaN)을 여기서 닫는다. */
45
+ const 기본수 = Number(defaultPageSize) || 25;
46
+ const perPage = Number(pageSize) || 기본수;
47
+ /* **검색어와 쪽크기를 여기서 넣는다** — 부르는 쪽이 적으면 빠뜨릴 수 있고, 빠뜨려도 안 빨개진다.
48
+ 이어 붙이는 것은 `usePageResetOnChange` 가 글 하나를 받기 때문이고, 값이 글·수·참거짓뿐이라
49
+ `[object Object]` 로 뭉개질 일이 없다(타입이 막는다). */
50
+ usePageResetOnChange([query, pageSize, ...(resetOn ?? [])].map(String).join('|'), setPage);
51
+ return { query, setQuery, pageSize, setPageSize, perPage, page, setPage };
52
+ }
@@ -24,6 +24,7 @@ export * from './hooks/useWordMeanings';
24
24
  export * from './hooks/useHoverAnchor';
25
25
  export * from './hooks/usePagedList';
26
26
  export * from './hooks/usePageResetOnChange';
27
+ export * from './hooks/useListControls';
27
28
  export * from './hooks/useToastState';
28
29
  export { useTrainingTypeLabels } from './hooks/useTrainingTypeLabels';
29
30
  export { CORE_VERSION } from './version';
@@ -23,6 +23,7 @@ export * from './hooks/useWordMeanings';
23
23
  export * from './hooks/useHoverAnchor';
24
24
  export * from './hooks/usePagedList';
25
25
  export * from './hooks/usePageResetOnChange';
26
+ export * from './hooks/useListControls';
26
27
  // 토스트 상태(문구 + 종류 + 재실행 번호). 회원 앱의 useToast 가 이 위에 얹혀 있다 —
27
28
  // 같은 관용구를 두 벌로 두지 않으려면 패키지 밖에서도 보여야 한다.
28
29
  export * from './hooks/useToastState';
@@ -13,6 +13,11 @@
13
13
  },
14
14
  "save": "Save",
15
15
  "confirm": "Confirm",
16
+ "discardConfirm": {
17
+ "title": "Discard your changes?",
18
+ "body": "If you do not save, everything you have entered will be lost.",
19
+ "confirm": "Discard"
20
+ },
16
21
  "close": "Close",
17
22
  "back": "Back",
18
23
  "emptyTrainingName": "Training name is empty.",
@@ -11,6 +11,11 @@
11
11
  },
12
12
  "save": "저장",
13
13
  "confirm": "확인",
14
+ "discardConfirm": {
15
+ "title": "입력을 취소할까요?",
16
+ "body": "저장하지 않으면 지금까지 입력한 내용이 사라집니다.",
17
+ "confirm": "입력 취소"
18
+ },
14
19
  "close": "닫기",
15
20
  "back": "뒤로",
16
21
  "emptyTrainingName": "훈련 이름이 비어 있습니다.",
@@ -43,3 +43,30 @@ export declare function recoverFromSkew(now?: number): boolean;
43
43
  * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
44
44
  */
45
45
  export declare function skewReloadPending(now?: number): boolean;
46
+ /**
47
+ * **위 복구를 창에 매단다** — 듣는 자리 둘을 한 번에 달고, 떼는 함수를 돌려준다.
48
+ *
49
+ * 복구하는 일(`recoverFromSkew`)은 코어에 있었는데 **어디에 어떻게 매다는지**가 소비자마다
50
+ * 한 벌씩이었다(말귀 회원앱·어드민의 `main.tsx`, 글자까지 같았다 — 2026-09-27). 그 열몇 줄에
51
+ * 조용히 틀어지는 조각이 둘 있다:
52
+ *
53
+ * 1. **`error` 는 캡처 단계로 들어야 한다**(셋째 인자 `true`). `link`·`style` 의 `error` 는
54
+ * **버블링하지 않아** 창까지 올라오지 않는다. 한쪽에서 그 `true` 를 빠뜨리면 그 앱만 CSS
55
+ * 청크 실패를 못 듣고, **화면은 뜨는데 스타일만 전부 빠진 채** 남는다(말귀 실제 QA 건 —
56
+ * 매니저 화면이 기본 스타일로 무너졌다). 스크립트는 멀쩡히 도니 오류도 안 난다.
57
+ * 2. **`vite:preloadError` 는 막아야(`preventDefault`)** Vite 헬퍼가 오류를 안 던진다.
58
+ * 막지 않으면 새로고침과 오류 화면이 함께 난다(이 파일 머리말).
59
+ *
60
+ * CSS 쪽은 `preventDefault` 를 하지 않는다 — 막을 기본 동작이 없고, 그 오류는 다른
61
+ * 듣는 이(오류 보고 등)에게도 그대로 가는 편이 맞다.
62
+ *
63
+ * ```ts
64
+ * // main.tsx — 한 줄이면 된다
65
+ * installSkewRecovery()
66
+ * ```
67
+ *
68
+ * 창이 없는 곳(SSR·시험)에서는 아무것도 안 달고 빈 함수를 돌려준다.
69
+ * @returns 떼는 함수. 앱 시작점에서는 뗄 일이 없지만, 시험이 뒷정리할 수 있어야 한다.
70
+ * @public
71
+ */
72
+ export declare function installSkewRecovery(): () => void;
@@ -68,3 +68,47 @@ export function skewReloadPending(now = Date.now()) {
68
68
  const last = 마지막시각();
69
69
  return last > 0 && now - last <= COOLDOWN_MS;
70
70
  }
71
+ /**
72
+ * **위 복구를 창에 매단다** — 듣는 자리 둘을 한 번에 달고, 떼는 함수를 돌려준다.
73
+ *
74
+ * 복구하는 일(`recoverFromSkew`)은 코어에 있었는데 **어디에 어떻게 매다는지**가 소비자마다
75
+ * 한 벌씩이었다(말귀 회원앱·어드민의 `main.tsx`, 글자까지 같았다 — 2026-09-27). 그 열몇 줄에
76
+ * 조용히 틀어지는 조각이 둘 있다:
77
+ *
78
+ * 1. **`error` 는 캡처 단계로 들어야 한다**(셋째 인자 `true`). `link`·`style` 의 `error` 는
79
+ * **버블링하지 않아** 창까지 올라오지 않는다. 한쪽에서 그 `true` 를 빠뜨리면 그 앱만 CSS
80
+ * 청크 실패를 못 듣고, **화면은 뜨는데 스타일만 전부 빠진 채** 남는다(말귀 실제 QA 건 —
81
+ * 매니저 화면이 기본 스타일로 무너졌다). 스크립트는 멀쩡히 도니 오류도 안 난다.
82
+ * 2. **`vite:preloadError` 는 막아야(`preventDefault`)** Vite 헬퍼가 오류를 안 던진다.
83
+ * 막지 않으면 새로고침과 오류 화면이 함께 난다(이 파일 머리말).
84
+ *
85
+ * CSS 쪽은 `preventDefault` 를 하지 않는다 — 막을 기본 동작이 없고, 그 오류는 다른
86
+ * 듣는 이(오류 보고 등)에게도 그대로 가는 편이 맞다.
87
+ *
88
+ * ```ts
89
+ * // main.tsx — 한 줄이면 된다
90
+ * installSkewRecovery()
91
+ * ```
92
+ *
93
+ * 창이 없는 곳(SSR·시험)에서는 아무것도 안 달고 빈 함수를 돌려준다.
94
+ * @returns 떼는 함수. 앱 시작점에서는 뗄 일이 없지만, 시험이 뒷정리할 수 있어야 한다.
95
+ * @public
96
+ */
97
+ export function installSkewRecovery() {
98
+ if (typeof window === 'undefined')
99
+ return () => { };
100
+ const 청크실패 = (e) => { if (recoverFromSkew())
101
+ e.preventDefault(); };
102
+ /* 스타일시트만 본다 — 그림 하나가 깨졌다고 판을 새로 받으면 안 된다 */
103
+ const 스타일실패 = (e) => {
104
+ const el = e.target;
105
+ if (el instanceof HTMLLinkElement && el.rel === 'stylesheet')
106
+ recoverFromSkew();
107
+ };
108
+ window.addEventListener('vite:preloadError', 청크실패);
109
+ window.addEventListener('error', 스타일실패, true);
110
+ return () => {
111
+ window.removeEventListener('vite:preloadError', 청크실패);
112
+ window.removeEventListener('error', 스타일실패, true);
113
+ };
114
+ }
@@ -3,26 +3,26 @@
3
3
  * 입력에 대시가 있든 없든, 어떤 포맷이든 처리.
4
4
  */
5
5
  export declare function formatPhone(raw: string): string;
6
+ export declare const PHONE_REGEX: RegExp;
6
7
  /**
7
- * 연락처가 **`formatPhone` 이 끝까지 채워 낸 모양** 인가.
8
- *
9
- * 만드는 쪽(`formatPhone`)은 코어인데 받는 쪽(형식 검사)은 소비자마다 한 벌씩 두고 있었다 —
10
- * 말귀 회원앱·어드민에 같은 정규식이 한 벌씩 있었고, 둘이 같은지는 짝 시험이 지켰지만
11
- * **코어가 만든 모양과 맞는지는 아무도 안 쟀다.** 실제로 코어가 050X·0303(앞자리 네 자리)을
12
- * 받았을 때 그 두 벌도 손으로 함께 고쳐야 했다. 그래서 짝을 여기 둔다 — 아래 시험이
13
- * "formatPhone 이 끝까지 채운 값은 늘 통과한다" 를 못 박는다.
8
+ * **대표번호를 뺀 것만** 받는 자 — `PHONE_REGEX` 에서 대표번호 갈래를 뺀 것이다.
14
9
  *
15
- * `formatPhone` 은 **덜 채운 입력을 그대로 돌려준다**(`010-12`). 그래서 빈값 검사만으로는
16
- * 모자라고, 이 함수가 그 자리를 맡는다.
17
- *
18
- * 받는 꼴 — 휴대폰·지역번호(02·0NN)·050X 안심번호·0303 착신전환·**1588 등 대표번호**.
19
- * 대표번호는 `formatPhone` 이 `1588-1234` 로 만들어 내므로 여기서도 받는다
20
- * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
10
+ * 말귀 회원앱·어드민이 `isValidPhone(phone) && phone.startsWith('0')` 를 한 벌씩 들고
11
+ * 있었다(2026-09-27). 그 칸들은 **사람에게 거는 번호**를 받는 자리라 `1588-1234` 를
12
+ * 거절해 왔다 — 뜻은 맞는데 **`'0'` 으로 시작하는가**로 적혀 있었다. 그것이 지금 같은
13
+ * 뜻인 것은 `PHONE_REGEX` 의 비-0 갈래가 대표번호 하나뿐이라서일 뿐이고, 0 으로 시작하지
14
+ * 않는 대역이 하나 더 생기는 날 **두 앱이 조용히 그것을 받게 된다.** 뜻대로 적어 둔다.
21
15
  * @public
22
16
  */
23
- export declare const PHONE_REGEX: RegExp;
17
+ export declare const PERSONAL_PHONE_REGEX: RegExp;
24
18
  /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
25
19
  export declare function isValidPhone(phone: string): boolean;
20
+ /**
21
+ * 연락처가 **사람에게 거는 번호**의 모양인가 — 대표번호(15xx~19xx)는 안 받는다.
22
+ * 규칙은 `PERSONAL_PHONE_REGEX`, 대표번호까지 받으려면 `isValidPhone`.
23
+ * @public
24
+ */
25
+ export declare function isPersonalPhone(phone: string): boolean;
26
26
  /**
27
27
  * 날짜를 앱 표준 형식 YYYY.MM.DD 로 정규화.
28
28
  * 지원 입력: YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD / ISO datetime 문자열
@@ -63,11 +63,37 @@ export function formatPhone(raw) {
63
63
  * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
64
64
  * @public
65
65
  */
66
- export const PHONE_REGEX = /^(?:(?:0\d{1,2}|050\d|0303)-\d{3,4}-\d{4}|1[5-9]\d{2}-\d{4})$/;
66
+ /**
67
+ * **사람에게 거는 번호** — 휴대폰 · 지역번호(02·0NN) · 050X 안심번호 · 0303 착신전환.
68
+ * 새 대역은 여기에만 더하면 아래 둘이 함께 받는다.
69
+ */
70
+ const 개인번호 = String.raw `(?:0\d{1,2}|050\d|0303)-\d{3,4}-\d{4}`;
71
+ /** **대표번호**(1588 등) — 거는 곳이 사람이 아니라 기관이다 */
72
+ const 대표번호 = String.raw `1[5-9]\d{2}-\d{4}`;
73
+ export const PHONE_REGEX = new RegExp(`^(?:${개인번호}|${대표번호})$`);
74
+ /**
75
+ * **대표번호를 뺀 것만** 받는 자 — `PHONE_REGEX` 에서 대표번호 갈래를 뺀 것이다.
76
+ *
77
+ * 말귀 회원앱·어드민이 `isValidPhone(phone) && phone.startsWith('0')` 를 한 벌씩 들고
78
+ * 있었다(2026-09-27). 그 칸들은 **사람에게 거는 번호**를 받는 자리라 `1588-1234` 를
79
+ * 거절해 왔다 — 뜻은 맞는데 **`'0'` 으로 시작하는가**로 적혀 있었다. 그것이 지금 같은
80
+ * 뜻인 것은 `PHONE_REGEX` 의 비-0 갈래가 대표번호 하나뿐이라서일 뿐이고, 0 으로 시작하지
81
+ * 않는 대역이 하나 더 생기는 날 **두 앱이 조용히 그것을 받게 된다.** 뜻대로 적어 둔다.
82
+ * @public
83
+ */
84
+ export const PERSONAL_PHONE_REGEX = new RegExp(`^${개인번호}$`);
67
85
  /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
68
86
  export function isValidPhone(phone) {
69
87
  return PHONE_REGEX.test(phone);
70
88
  }
89
+ /**
90
+ * 연락처가 **사람에게 거는 번호**의 모양인가 — 대표번호(15xx~19xx)는 안 받는다.
91
+ * 규칙은 `PERSONAL_PHONE_REGEX`, 대표번호까지 받으려면 `isValidPhone`.
92
+ * @public
93
+ */
94
+ export function isPersonalPhone(phone) {
95
+ return PERSONAL_PHONE_REGEX.test(phone);
96
+ }
71
97
  /**
72
98
  * 날짜를 앱 표준 형식 YYYY.MM.DD 로 정규화.
73
99
  * 지원 입력: YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD / ISO datetime 문자열
@@ -0,0 +1,36 @@
1
+ /** 이 자가 보는 것은 **이것뿐이다** — 세션의 나머지는 알 필요가 없다 @public */
2
+ export interface TabSessionLike {
3
+ access_token: string;
4
+ }
5
+ /**
6
+ * **이 이벤트가 이 탭에 실제로 반영된 것인가.**
7
+ *
8
+ * 로그인을 탭마다 가르려고 `auth.storage` 를 `sessionStorage` 로 두어도, GoTrue 의
9
+ * `BroadcastChannel` 은 **저장소 종류와 무관하게** 세션 이벤트를 같은 브라우저의 모든 탭에
10
+ * 뿌린다(여러 탭을 맞추라고 만든 길이다). 그래서 받은 세션을 그대로 믿으면 **다른 탭에서
11
+ * 로그인한 세션이 이 탭 화면에 얹힌다** — 로그인된 것처럼 그려지는데 이 탭의 저장소는 비어
12
+ * 있어 API 호출은 전부 미인증으로 실패한다. 반대로 이 탭에 제 세션이 있는데 다른 탭의
13
+ * 로그아웃이 날아와 뒤섞이기도 한다. 말귀 어드민에서는 그렇게 얹히는 것이 `ROLE_ADMIN`
14
+ * 세션이다.
15
+ *
16
+ * 가르는 법은 하나다 — `getSession()` 으로 **이 탭 저장소를 다시 읽어** 받은 것과 맞대 본다.
17
+ * 그 한 줄과 여덟 줄짜리 까닭이 말귀 회원앱·어드민에 한 벌씩 있었다(2026-09-27).
18
+ *
19
+ * **로그아웃(세션 없음)은 늘 받는다.** 지울 것이 없다는 말은 어느 탭에서 왔든 참이고,
20
+ * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
21
+ *
22
+ * ```ts
23
+ * onAuthStateChange((event, 받은것) => {
24
+ * getSession().then(({ data }) => {
25
+ * const 이탭것 = data.session
26
+ * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
27
+ * …이탭것 으로 상태를 맞춘다
28
+ * })
29
+ * })
30
+ * ```
31
+ *
32
+ * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
33
+ * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
34
+ * @public
35
+ */
36
+ export declare function isSessionForThisTab(받은것: TabSessionLike | null | undefined, 이탭것: TabSessionLike | null | undefined): boolean;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * **이 이벤트가 이 탭에 실제로 반영된 것인가.**
3
+ *
4
+ * 로그인을 탭마다 가르려고 `auth.storage` 를 `sessionStorage` 로 두어도, GoTrue 의
5
+ * `BroadcastChannel` 은 **저장소 종류와 무관하게** 세션 이벤트를 같은 브라우저의 모든 탭에
6
+ * 뿌린다(여러 탭을 맞추라고 만든 길이다). 그래서 받은 세션을 그대로 믿으면 **다른 탭에서
7
+ * 로그인한 세션이 이 탭 화면에 얹힌다** — 로그인된 것처럼 그려지는데 이 탭의 저장소는 비어
8
+ * 있어 API 호출은 전부 미인증으로 실패한다. 반대로 이 탭에 제 세션이 있는데 다른 탭의
9
+ * 로그아웃이 날아와 뒤섞이기도 한다. 말귀 어드민에서는 그렇게 얹히는 것이 `ROLE_ADMIN`
10
+ * 세션이다.
11
+ *
12
+ * 가르는 법은 하나다 — `getSession()` 으로 **이 탭 저장소를 다시 읽어** 받은 것과 맞대 본다.
13
+ * 그 한 줄과 여덟 줄짜리 까닭이 말귀 회원앱·어드민에 한 벌씩 있었다(2026-09-27).
14
+ *
15
+ * **로그아웃(세션 없음)은 늘 받는다.** 지울 것이 없다는 말은 어느 탭에서 왔든 참이고,
16
+ * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
17
+ *
18
+ * ```ts
19
+ * onAuthStateChange((event, 받은것) => {
20
+ * getSession().then(({ data }) => {
21
+ * const 이탭것 = data.session
22
+ * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
23
+ * …이탭것 으로 상태를 맞춘다
24
+ * })
25
+ * })
26
+ * ```
27
+ *
28
+ * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
29
+ * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
30
+ * @public
31
+ */
32
+ export function isSessionForThisTab(받은것, 이탭것) {
33
+ if (!받은것)
34
+ return true;
35
+ return !!이탭것 && 이탭것.access_token === 받은것.access_token;
36
+ }
@@ -36,7 +36,7 @@ export { createTTSUrlResolver } from './utils/ttsResolver';
36
36
  export type { CreateTTSResolverOptions } from './utils/ttsResolver';
37
37
  export { toStorageName, toBase64UrlSafe, officialSoundPath } from './utils/encoding';
38
38
  export { copyText } from './utils/clipboard';
39
- export { formatPhone, PHONE_REGEX, isValidPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
39
+ export { formatPhone, PHONE_REGEX, isValidPhone, PERSONAL_PHONE_REGEX, isPersonalPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
40
40
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
41
41
  export type { TopicType, TopicSubType, TrainingType, TrainingCategoryConfig } from './utils/categories';
42
42
  export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
@@ -55,7 +55,9 @@ export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wor
55
55
  export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
56
56
  export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, type PlanTier, type DataLevel, type PlanFeatures, type PlanLimits, } from './utils/planFeatures';
57
57
  export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
58
- export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
58
+ export { recoverFromSkew, skewReloadPending, installSkewRecovery } from './utils/chunkSkew';
59
+ export { isSessionForThisTab } from './utils/sessionTab';
60
+ export type { TabSessionLike } from './utils/sessionTab';
59
61
  export { lazyPage } from './utils/lazyPage';
60
62
  export { prefetchChunk } from './utils/prefetchChunk';
61
63
  export { toWordEntry } from './utils/wordRow';
@@ -25,7 +25,7 @@ export { configureNoiseSettingsStore, loadNoiseSettings, saveNoiseSettings, repo
25
25
  export { createTTSUrlResolver } from './utils/ttsResolver';
26
26
  export { toStorageName, toBase64UrlSafe, officialSoundPath } from './utils/encoding';
27
27
  export { copyText } from './utils/clipboard';
28
- export { formatPhone, PHONE_REGEX, isValidPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
28
+ export { formatPhone, PHONE_REGEX, isValidPhone, PERSONAL_PHONE_REGEX, isPersonalPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
29
29
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
30
30
  export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
31
31
  export { getSavedTheme, applyTheme, initTheme } from './utils/theme';
@@ -44,7 +44,8 @@ export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, } fr
44
44
  말귀어드민(단어 제안 관리)이 같은 숫자를 봐야 하는데, 한쪽 화면 폴더에 두면
45
45
  다른 쪽이 그 폴더 없이는 빌드되지 않는다. 값과 순수 함수뿐이다. */
46
46
  export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
47
- export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
47
+ export { recoverFromSkew, skewReloadPending, installSkewRecovery } from './utils/chunkSkew';
48
+ export { isSessionForThisTab } from './utils/sessionTab';
48
49
  export { lazyPage } from './utils/lazyPage';
49
50
  export { prefetchChunk } from './utils/prefetchChunk';
50
51
  export { toWordEntry } from './utils/wordRow';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.200.0";
49
+ export declare const CORE_VERSION = "0.201.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.200.0';
49
+ export const CORE_VERSION = '0.201.0';