@ihabdevteam/core 0.196.0 → 0.198.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.196.0";
1629
+ export declare const CORE_VERSION = "0.198.0";
1630
1630
 
1631
1631
  /** @public */
1632
1632
  declare const coreDefault: Record<string, unknown>;
@@ -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);
@@ -130,13 +134,13 @@
130
134
  .header__branding { height: 80px; }
131
135
  /* Collapsed: rounded square button (52x52) centered in 72px rail */
132
136
  .header .side-nav__menu {
133
- padding: 0 10px;
137
+ padding: 0 var(--spacing-xs);
134
138
  transition: padding 180ms ease;
135
139
  /* **접힌 레일에서는 스크롤바 자리를 비우지 않는다.**
136
140
  72px 에서 양쪽 gutter(클래식 11px × 2)를 떼면 항목이 28px 로 짓눌려 24px
137
141
  아이콘이 10px 삐져나간다(실측). 아이콘만 있는 좁은 레일에는 스크롤바를 그릴
138
142
  자리가 애초에 없다 — 막대는 숨기고 휠·터치·키보드 스크롤은 그대로 둔다.
139
- 좌우는 padding 만으로 같다(실측: 오버레이·클래식 모두 좌 11 / 우 11, 항목 50). */
143
+ 좌우는 padding 만으로 같다(실측 8px 기준: 좌 9 / 우 9, 항목 52). */
140
144
  scrollbar-gutter: auto;
141
145
  scrollbar-width: none;
142
146
  }
@@ -146,18 +150,41 @@
146
150
  padding: 0 14px;
147
151
  gap: 0;
148
152
  border-radius: var(--radius-card);
149
- transition: padding 180ms ease, gap 180ms ease;
153
+ /* **단추를 52 로 못 박고 가운데 세운다**(사장님 지시 2026-09-25). 여백만 8px 으로 낮추면
154
+ 항목이 자리를 다 먹어 54 가 되고, 위 14px 셈이 어긋나 아이콘이 1px 오른쪽으로 밀린다.
155
+ 52 로 두면 그 셈이 그대로 맞아 아이콘이 14 / 14 로 선다. */
156
+ width: 52px;
157
+ margin: 0 auto;
158
+ transition: padding 180ms ease, gap 180ms ease, width 180ms ease;
159
+ }
160
+
161
+ /* **발치도 가운데로.** 발치 줄은 좌우 여백이 없어(`padding: 0`) 단추 40px 이 왼쪽에 붙었고,
162
+ 레일 가운데(37)보다 **5px 왼쪽**에 서서 위 아이콘들과 눈에 띄게 어긋났다(말귀 실측
163
+ 2026-09-25 — 사장님이 보신 어긋남이 이것이다). 펼친 사이드바에서는 발치도 폭을 꽉 채우므로
164
+ 여기서만 가운데로 세운다. */
165
+ .header .side-nav__footer {
166
+ align-items: center;
150
167
  }
151
168
 
152
169
  .header:hover .side-nav__menu,
153
170
  .header:focus-within .side-nav__menu {
154
- padding: 0 10px;
171
+ padding: 0 var(--spacing-xs);
155
172
  }
156
173
 
157
174
  .header:hover .side-nav__item,
158
175
  .header:focus-within .side-nav__item {
159
176
  padding: 0 16px;
160
177
  gap: 10px;
178
+ /* 레일이 220px 로 펼쳐지는 동안에는 **폭 고정을 푼다** — 안 풀면 항목만 52px 로 남아
179
+ 이름표가 설 자리가 없다(접힌 꼴의 가운데 맞춤이 펼친 꼴까지 따라오면 안 된다). */
180
+ width: 100%;
181
+ margin: 0;
182
+ }
183
+
184
+ /* 발치도 같다 — 펼쳐지면 줄이 폭을 꽉 채운다 */
185
+ .header:hover .side-nav__footer,
186
+ .header:focus-within .side-nav__footer {
187
+ align-items: stretch;
161
188
  }
162
189
 
163
190
  /* Hide label completely from layout when collapsed; fade in on hover */
@@ -12,11 +12,44 @@
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 인 것은 고른 값이 아니라 **브라우저가 정하는 값**이다. `styles.css` 가 `*` 에
20
+ `scrollbar-width: thin` 을 걸어 두었고, Chrome 은 표준 속성이 있으면 `::-webkit-scrollbar` 의
21
+ 폭을 안 본다 — 이 저장소가 스크롤바 폭을 적어 둔 자리들(6px·4px)은 **아무 일도 안 하고
22
+ 있었다**(2026-09-25 실측: thin 11 · auto 15 · none 0, webkit 규칙이 있든 없든 같다).
23
+ 그러니 이 11 은 기계·설정에 따라 달라질 수 있는 수다. macOS 오버레이 스크롤바에서는 0 이라
24
+ 오른쪽이 1px 만 남는다 — 그 환경에서 좌우가 어긋나 보이면 그때 다시 정하면 된다.
25
+
26
+ `stable` 은 **오른쪽에만** 자리를 비운다. 스크롤이 생겼다 없어져도 메뉴가 밀리지 않는다. */
27
+ /* **선택·호버 단추가 가운데 서야 한다** — 그래서 좌우를 같은 패딩으로 만든다.
28
+ 테두리 1px 이 양쪽에 있으므로 그만큼 빼, 보이는 틈이 좌우 **12px** 이 된다.
29
+
30
+ **스크롤바 자리는 예약하지 않는다**(`scrollbar-gutter` 를 안 쓴다). 예약하면 그 11px 이
31
+ 오른쪽에만 붙어 단추가 그만큼 왼쪽으로 밀린다 — 게다가 그 자리는 환경마다 값이 달라
32
+ (클래식 11 · macOS 오버레이 0) 어떤 화면에서는 맞고 어떤 화면에서는 어긋난다.
33
+ 막대가 거의 안 보이는 자리에 쓰는 냅이라, 그 자리를 여백으로 돌려 쓰는 편이 낫다
34
+ (사장님 지시 2026-09-25).
35
+
36
+ **막대도 안 그린다**(`scrollbar-width: none`). 안 그러면 넘치는 순간 막대가 11px 을 가져가
37
+ 오른쪽만 23 이 되어(실측) 단추가 다시 왼쪽으로 밀린다 — 자리를 예약하든(늘 밀림) 안 하든
38
+ (넘칠 때만 밀림) 막대가 보이는 한 가운데는 지켜지지 않는다. 굴리기·터치·키보드 스크롤은
39
+ 그대로다. 접힌 72px 레일이 같은 까닭으로 이미 그렇게 하고 있다(Header.css).
40
+
41
+ 잃는 것은 **"더 있다" 는 신호**다. 메뉴가 길어 잘릴 때 막대가 알려 주던 것이 없어진다 —
42
+ 항목이 화면을 넘는 일이 드문 자리라 가운데 맞춤을 택했다(사장님 지시 2026-09-25). */
43
+ padding-left: calc(var(--spacing-s) - 1px);
44
+ padding-right: calc(var(--spacing-s) - 1px);
45
+ scrollbar-width: none;
46
+ /* **막대가 끝나는 자리를 내용에 맞춘다.** 스크롤바는 이 통의 위아래 끝까지 그려지는데,
47
+ 발치가 제 안쪽에 아래 여백 12px 을 두고 있어 **내용은 12 위에서 끝나고 막대만 바닥까지**
48
+ 내려갔다(사장님 지적 2026-09-25). 그 12 를 통 **바깥**(margin)으로 옮기면 통 자체가 12 위에서
49
+ 끝나므로 막대도 거기서 끝난다 — 보이는 여백은 그대로다.
50
+ 위도 같다: 목록이 위 여백 4px 을 안에 두고 있어 막대가 첫 단추보다 4px 위에서 시작했다. */
51
+ margin-top: 4px;
52
+ margin-bottom: var(--spacing-s);
20
53
  /* 스크롤바는 padding 바깥·border 안쪽에 그려진다 — 투명 테두리를 두면 그만큼
21
54
  안쪽으로 들어와, 콘텐츠 영역과 맞닿은 경계선에 딱 붙지 않는다. 왼쪽에도 같은
22
55
  폭을 둬 짝을 맞춘다. */
@@ -25,30 +58,62 @@
25
58
  background-clip: padding-box;
26
59
  overflow-y: auto; /* 항상 단일 스크롤 컨테이너 */
27
60
  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
61
  overscroll-behavior: contain;
37
62
  -webkit-overflow-scrolling: touch;
38
- scrollbar-width: thin;
39
- /* 트랙은 투명 — 사이드바 배경이 모드마다 다르다(말귀는 단색, 매니저는 그라디언트).
40
- 특정 색으로 고정하면 매니저 모드에서 흰 세로줄로 튄다. thumb만 색을 준다. */
41
- scrollbar-color: var(--color-darkWhite) transparent;
42
63
  }
43
64
 
44
- .side-nav__menu::-webkit-scrollbar { width: 4px; }
45
- .side-nav__menu::-webkit-scrollbar-track { background: transparent; }
46
- .side-nav__menu::-webkit-scrollbar-thumb {
65
+ /* 표준 속성을 모르는 옛 WebKit 몫 — 접힌 레일과 같은 처리 */
66
+ .side-nav__menu::-webkit-scrollbar { display: none; }
67
+
68
+ /* ── 떠 있는 스크롤 막대 ───────────────────────────────────
69
+ 네이티브 막대는 **폭을 정할 수 없고**(전역 `scrollbar-width: thin` 이 이기므로 4px 을 적어도
70
+ 11px 로 선다) **자리를 먹어** 항목을 왼쪽으로 민다(실측: 오른쪽만 23px). 그래서 직접 그린다 —
71
+ 떠 있으니 자리를 안 먹고, 항목은 좌우 12px 그대로 가운데에 선다.
72
+
73
+ `sticky` 한 겹이 구르는 통 안에서 제자리를 지키고(높이 0 이라 흐름을 안 밀어낸다), 그 안의
74
+ 막대가 `top` 만 옮겨 다닌다. 굴리는 일은 통이 그대로 한다 — 이것은 어디쯤인지 알리는 그림이다. */
75
+ .side-nav__scroll {
76
+ position: sticky;
77
+ top: 0;
78
+ height: 0;
79
+ overflow: visible;
80
+ z-index: 1;
81
+ /* 이 겹은 통 전체를 덮으므로 **손짓을 가로채면 안 된다** — 막대만 도로 받는다 */
82
+ pointer-events: none;
83
+ /* **통의 안쪽 여백 바깥까지 편다.** 안 펴면 이 겹이 내용 폭에서 끝나, 막대가 항목 위에
84
+ 얹힌다. 여백만큼 음수로 밀어 두면 막대를 그 **여백 안**에 앉힐 수 있다. */
85
+ margin-left: calc(-1 * (var(--spacing-s) - 1px));
86
+ margin-right: calc(-1 * (var(--spacing-s) - 1px));
87
+ }
88
+ .side-nav__scroll-thumb {
89
+ position: absolute;
90
+ /* **오른쪽 여백 한가운데.** 여백 11 에서 막대 폭을 뺀 나머지를 반씩 나눈다 —
91
+ 항목도 안 덮고 테두리에도 안 닿는다(사장님 지시 2026-09-25 "12 패딩 사이에 딱").
92
+ 폭 6 은 전역 CSS 가 `::-webkit-scrollbar` 에 적어 두고도 못 지키던 값과 같다
93
+ (4 는 너무 얇았다 — 사장님 지시). */
94
+ /* 셈으로는 여백 한가운데인데 **눈에는 1px 왼쪽으로 보인다**(사장님 지적 2026-09-25) — 그럴
95
+ 만하다: 이 여백 오른쪽에는 통의 투명 테두리와 헤더의 테두리가 더 붙어 있어, 보이는 띠는
96
+ 셈한 11px 보다 넓다. 그만큼 오른쪽으로 민다. */
97
+ right: calc((var(--spacing-s) - 1px - 6px) / 2 - 1px);
98
+ width: 6px;
99
+ border-radius: 3px;
47
100
  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));
101
+ /* **잡히는 자리다.** 손짓을 도로 받아 얹으면 짙어지고, 끌면 통이 따라 구른다.
102
+ 손가락으로 겨누기엔 6px 이 좁아 위아래로만 두고, 좌우는 보이지 않는 여백을 덧대
103
+ (`::before`) 겨누는 자리를 넓힌다 — 보이는 굵기는 그대로다. */
104
+ pointer-events: auto;
105
+ touch-action: none;
106
+ transition: background-color 160ms ease, opacity 180ms ease;
107
+ }
108
+ .side-nav__scroll-thumb::before {
109
+ content: '';
110
+ position: absolute;
111
+ /* 여백(11) 안에서 좌우로 더 집는다 — 보이는 막대는 6px 그대로 */
112
+ inset: 0 -4px;
113
+ }
114
+ .side-nav__scroll-thumb:hover,
115
+ .side-nav__scroll-thumb:active {
116
+ background: var(--color-lightGray);
52
117
  }
53
118
 
54
119
  /* ── list: 자체 스크롤 없음 → side-nav__menu 단일 스크롤에 위임 ──────────────── */
@@ -61,8 +126,10 @@
61
126
  overflow: visible;
62
127
  /* 예전엔 padding: 4px 16px + margin: 0 -16px 였는데 좌우가 정확히 상쇄돼
63
128
  항목 위치에는 아무 영향이 없고, 폭만 228px로 부풀어 부모(220px)를 넘었다
64
- (overflow-x: hidden에 잘려 보이지 않았을 뿐). 세로 여백만 남긴다. */
65
- padding: 4px 0;
129
+ (overflow-x: hidden에 잘려 보이지 않았을 뿐).
130
+ **위 여백 4px 은 통 바깥(margin-top)으로 옮겼다** — 안에 두면 막대가 첫 단추보다 그만큼
131
+ 위에서 시작한다(2026-09-25). 아래 4px 은 발치와의 사이라 그대로 둔다. */
132
+ padding: 0 0 4px;
66
133
  }
67
134
 
68
135
  /* ── footer: 항상 list 아래에 위치, 줄어들지 않음 ─────────────────────────── */
@@ -76,11 +143,16 @@
76
143
  flex-shrink: 0;
77
144
  /* 위아래 여백을 좌우와 같은 12px로 — 좌측 padding, 우측 'gutter + 테두리'와 맞춘다. */
78
145
  margin-top: var(--spacing-s);
79
- padding: 0 0 var(--spacing-s) 0;
146
+ /* 아래 여백은 통 바깥(`.side-nav__menu` 의 margin-bottom)으로 옮겼다 — 막대가 내용과 같은
147
+ 자리에서 끝나게 하려는 것이고, 보이는 여백은 그대로 12px 이다. */
148
+ padding: 0;
80
149
  display: flex;
81
150
  flex-direction: column;
82
151
  gap: var(--spacing-s);
83
- align-items: left;
152
+ /* **`left` 는 `align-items` 에 없는 값이다** — 브라우저가 버리고 기본값(stretch)으로 그렸다.
153
+ 뜻은 그대로 두되(펼친 사이드바에서는 발치 줄도 폭을 꽉 채우는 것이 맞다) 값을 제 이름으로
154
+ 적는다. 접힌 레일에서 가운데로 세우는 것은 `Header.css` 가 따로 한다(2026-09-25). */
155
+ align-items: stretch;
84
156
  }
85
157
 
86
158
  .side-nav__separator {
@@ -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)
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.196.0";
49
+ export declare const CORE_VERSION = "0.198.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.196.0';
49
+ export const CORE_VERSION = '0.198.0';