@ihabdevteam/core 0.183.2 → 0.185.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 (39) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +5 -5
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +19 -19
  7. package/dist/ihabdevteam-core.d.ts +9 -3
  8. package/dist/packages/core/src/components/BottomNav.js +34 -22
  9. package/dist/packages/core/src/components/Calendar.css +32 -5
  10. package/dist/packages/core/src/components/Calendar.js +1 -1
  11. package/dist/packages/core/src/components/ChartSection.css +6 -0
  12. package/dist/packages/core/src/components/EmptyState.css +7 -4
  13. package/dist/packages/core/src/components/ErrorState.js +1 -1
  14. package/dist/packages/core/src/components/FilterPanel.js +5 -2
  15. package/dist/packages/core/src/components/Header.d.ts +3 -1
  16. package/dist/packages/core/src/components/Header.js +6 -4
  17. package/dist/packages/core/src/components/PageTitle.css +7 -3
  18. package/dist/packages/core/src/components/Popup/Popup.css +15 -6
  19. package/dist/packages/core/src/components/Section.css +6 -0
  20. package/dist/packages/core/src/components/SideNav.d.ts +2 -0
  21. package/dist/packages/core/src/components/SideNav.js +5 -5
  22. package/dist/packages/core/src/components/Toolbar.css +21 -0
  23. package/dist/packages/core/src/components/Toolbar.js +1 -1
  24. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
  25. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.css +14 -0
  26. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +3 -1
  27. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +30 -4
  28. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +12 -0
  29. package/dist/packages/core/src/components/Training/WordTablePopup.css +18 -0
  30. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.css +25 -1
  31. package/dist/packages/core/src/components/WordTable.css +19 -0
  32. package/dist/packages/core/src/components/WordTable.js +21 -2
  33. package/dist/packages/core/src/components/useFitScale.js +17 -1
  34. package/dist/packages/core/src/i18n/locales/en.json +4 -2
  35. package/dist/packages/core/src/i18n/locales/ko.json +4 -2
  36. package/dist/packages/core/src/version.d.ts +1 -1
  37. package/dist/packages/core/src/version.js +1 -1
  38. package/dist/utils.cjs.js +1 -1
  39. package/package.json +1 -1
@@ -1600,7 +1600,7 @@ export declare function copyText(text: string): Promise<boolean>;
1600
1600
  *
1601
1601
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1602
1602
  */
1603
- export declare const CORE_VERSION = "0.183.2";
1603
+ export declare const CORE_VERSION = "0.185.0";
1604
1604
 
1605
1605
  /** @public */
1606
1606
  declare const coreDefault: Record<string, unknown>;
@@ -2734,7 +2734,7 @@ export declare function hasStructureContrast(A: Decomp, B: Decomp): boolean;
2734
2734
  *
2735
2735
  * @public
2736
2736
  */
2737
- export declare function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer }: {
2737
+ export declare function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer }: {
2738
2738
  activeKey?: string | null;
2739
2739
  onChange?: (key?: string) => void;
2740
2740
  /** 지금 모드의 내비 항목. 데스크톱은 SideNav, 모바일은 BottomNav 가 이 목록을 그린다. */
@@ -2752,6 +2752,8 @@ export declare function Header({ activeKey, onChange, items, malgwiItems: _malgw
2752
2752
  onAccount?: () => void;
2753
2753
  onSetting?: () => void;
2754
2754
  onHelp?: () => void;
2755
+ /** 주면 도움말과 계정 **사이** 에 '버그신고' 를 그린다 — 냅 바닥과 모바일 '…' 메뉴 둘 다. */
2756
+ onFeedback?: () => void;
2755
2757
  appMode?: 'malgwi' | 'manager';
2756
2758
  onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
2757
2759
  /**
@@ -6082,6 +6084,8 @@ export declare interface SideNavProps {
6082
6084
  onAccount?: () => void;
6083
6085
  onSetting?: () => void;
6084
6086
  onHelp?: () => void;
6087
+ /** 주면 도움말과 계정 사이에 '버그신고' 를 그린다. 안 주면 그 자리가 없다(`onDebug` 와 같은 결). */
6088
+ onFeedback?: () => void;
6085
6089
  /** 현재 앱 모드 ('malgwi' | 'manager') */
6086
6090
  appMode?: 'malgwi' | 'manager';
6087
6091
  /** 앱 모드 전환 콜백 */
@@ -7898,7 +7902,7 @@ export declare interface TrainingItemResult {
7898
7902
  export declare const TrainingPagePopup: TrainingPagePopupComponent;
7899
7903
 
7900
7904
  /** @public */
7901
- export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, }: TrainingPagePopupBottomProps): JSX.Element;
7905
+ export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): JSX.Element;
7902
7906
 
7903
7907
  /** @public */
7904
7908
  export declare type TrainingPagePopupBottomProps = {
@@ -7907,6 +7911,8 @@ export declare type TrainingPagePopupBottomProps = {
7907
7911
  onNext?: () => void;
7908
7912
  onResult?: () => void;
7909
7913
  stepIndex?: number;
7914
+ /** 결과 창이 떠 있는가 — 뜨는 순간 오디오 미터를 끈다(사용자 지시 2026-09-22). */
7915
+ resultOpen?: boolean;
7910
7916
  stepCount?: number;
7911
7917
  pageIndex?: number;
7912
7918
  /** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
@@ -23,43 +23,55 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
23
23
  const nextMode = appMode === 'manager' ? 'malgwi' : 'manager';
24
24
  const modeIcon = appMode === 'manager' ? 'arrow-left' : 'arrow-right';
25
25
  const modeLabel = appMode === 'manager' ? t('nav.modeLabelMalgwi', '말귀') : t('nav.modeLabelManager', '말귀 매니저');
26
- // 컨테이너 너비 측정 → 들어갈 수 있는 버튼 수 계산
27
- const itemsRef = useRef(null);
28
- const [containerWidth, setContainerWidth] = useState(0);
26
+ /* **항목이 쓸 수 있는 폭은 "…" 유무와 무관한 자리에서 잰다.**
27
+ 예전에는 `.bottom-nav__items` 를 쟀는데, 그 상자는 `flex:1` 이라 "…"(구분선 1px + 52px 칸)가
28
+ 서면 53px 줄어든다 — 즉 **판정 결과가 다시 잰 값을 바꾸는 순환**이었다. 지난 렌더에 "…" 가
29
+ 섰는지 기억해 53 을 되돌리는 보정을 뒀지만, 그 기억이 맞지 않는 길(유틸이 뒤늦게 붙는 화면,
30
+ 화면을 옮기며 항목 수가 바뀌는 길)에서는 보정이 어긋나 **다섯 칸을 249px 상자에 그려 양끝이
31
+ 잘렸다**(말귀 375px /word-training 실측 — 잘린 만큼은 누를 수도 없다). 화면마다 결과가 달랐다.
32
+ 이제 바 전체에서 왼쪽 묶음(모드 단추 + 구분선)만 뺀 값을 쓴다. 이 값은 몇 칸을 그리든,
33
+ "…" 가 섰든 아니든 같다. 그래서 기억도 보정도 필요 없다. */
34
+ const rootRef = useRef(null);
35
+ const [avail, setAvail] = useState(0);
29
36
  const itemsLen = items.length;
30
37
  useEffect(() => {
31
- const el = itemsRef.current;
32
- if (!el)
38
+ const root = rootRef.current;
39
+ if (!root)
33
40
  return;
34
- setContainerWidth(el.clientWidth);
41
+ const 재기 = () => {
42
+ const cs = typeof getComputedStyle === 'function' ? getComputedStyle(root) : null;
43
+ const 안쪽 = root.clientWidth - (parseFloat(cs?.paddingLeft ?? '') || 0) - (parseFloat(cs?.paddingRight ?? '') || 0);
44
+ // 왼쪽 묶음은 항목 수와 무관하게 늘 같은 폭이다(모드 전환을 안 쓰면 아예 없다)
45
+ const 왼 = root.querySelector('.bottom-nav__left');
46
+ const 왼구분선 = 왼?.nextElementSibling;
47
+ const 왼폭 = (왼?.offsetWidth ?? 0)
48
+ + (왼 && 왼구분선?.classList.contains('bottom-nav__divider') ? 왼구분선.offsetWidth : 0);
49
+ setAvail(Math.max(0, 안쪽 - 왼폭));
50
+ };
51
+ 재기();
35
52
  // ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
36
53
  // 그대로 던져 렌더가 통째로 깨진다. 위에서 초기값은 이미 쟀으므로 추적만 포기한다
37
54
  // (Popup·CrosswordGame·Tooltip 과 같은 처리).
38
55
  if (typeof ResizeObserver === 'undefined')
39
56
  return;
40
- const ro = new ResizeObserver(entries => setContainerWidth(entries[0].contentRect.width));
41
- ro.observe(el);
57
+ const ro = new ResizeObserver(재기);
58
+ ro.observe(root);
42
59
  return () => ro.disconnect();
43
60
  }, []);
44
- /* 잰 폭은 "…"(구분선 1px + 52px 칸)가 서 있으면 그만큼 줄어든 값이다. 그 폭으로만 판정하면 같은 폭·같은 항목인데 지나온 길에 따라
45
- 넘침이 달랐다(13개 매니저 메뉴 → 8개 말귀 메뉴로 바꾸면 들어갈 8개 중 하나가 "…" 에 갇힘). 유틸이 없을 때 지난 렌더에 "…" 가
46
- 서 있었다면(넘침 때문이든 그때의 유틸 때문이든) 그 폭을 되돌려 **"…" 없는 폭** 으로 판정하고, 넘치면 "…" 자리를 뺀 폭으로 센다. */
47
61
  const MORE_W = BTN_SIZE + 1;
48
- const 더보기섰음Ref = useRef(false);
49
- const 온폭 = containerWidth > 0 && moreItems.length === 0 && 더보기섰음Ref.current ? containerWidth + MORE_W : containerWidth;
50
- const 다들어감 = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= 온폭;
51
- // n개 버튼: n*52 + (n-1)*10 <= W → n <= (W + 10) / 62
52
- const n = containerWidth <= 0 ? itemsLen
53
- : moreItems.length > 0 ? Math.floor((containerWidth + BTN_GAP) / (BTN_SIZE + BTN_GAP))
54
- : 다들어감 ? itemsLen
55
- : Math.floor((온폭 - MORE_W + BTN_GAP) / (BTN_SIZE + BTN_GAP));
56
- const overflow = containerWidth > 0 && n < itemsLen;
62
+ /** 폭 w 에 들어가는 칸 수 — n칸은 n*52 + (n-1)*10 을 쓰므로 n <= (w + 10) / 62 */
63
+ const 칸수 = (w) => Math.max(0, Math.floor((w + BTN_GAP) / (BTN_SIZE + BTN_GAP)));
64
+ const 다들어감 = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= avail;
65
+ const n = avail <= 0 ? itemsLen
66
+ : (moreItems.length === 0 && 다들어감) ? itemsLen
67
+ // "…" 가 설 자리를 뺀 폭으로 센다 — 유틸이 있으면 "…" 는 늘 선다
68
+ : Math.min(itemsLen, 칸수(avail - MORE_W));
69
+ const overflow = avail > 0 && n < itemsLen;
57
70
  const maxVisible = overflow ? n : itemsLen;
58
71
  const visibleItems = items.slice(0, maxVisible);
59
72
  const hiddenItems = items.slice(maxVisible);
60
73
  // 넘친 nav 항목이 있거나 유틸(moreItems)이 있으면 더보기(…) 버튼을 노출
61
74
  const showMore = overflow || moreItems.length > 0;
62
- useEffect(() => { 더보기섰음Ref.current = showMore; });
63
75
  const computeMenuStyle = useCallback((rect, trigger) => ({
64
76
  position: 'fixed',
65
77
  bottom: window.innerHeight - rect.top + 8,
@@ -114,7 +126,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
114
126
  }], [hiddenItems, moreItems, activeKey, onChange, handleCloseMore]);
115
127
  // "…" 단추 배지 — 메뉴 안에서만 보이는 개수(넘친 항목 + 유틸)를 닫힌 채로도 알린다.
116
128
  const moreBadge = [...hiddenItems, ...moreItems].reduce((sum, it) => sum + badgeOf(it.badgeCount), 0);
117
- return (_jsxs("nav", { className: "bottom-nav", "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", ref: itemsRef, children: visibleItems.map((it, i) => {
129
+ return (_jsxs("nav", { className: "bottom-nav", ref: rootRef, "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", children: visibleItems.map((it, i) => {
118
130
  const count = badgeOf(it.badgeCount);
119
131
  return (
120
132
  // key 는 선택 prop 이다 — 없으면 자리 번호로(형제 key 가 모두 undefined 가 되던 것)
@@ -74,8 +74,15 @@
74
74
  .calendar-day-header:first-child { color: var(--color-error); }
75
75
  .calendar-day-header:last-child { color: var(--color-main); }
76
76
 
77
+ /* **칸은 제 폭을 우기지 않는다.** 예전에는 `min-width: 118px` 이 박혀 있고 그 상한을
78
+ 767px 이하에서만 풀었다. 그런데 칸이 좁아지는 것은 창이 아니라 **담긴 자리**가 정한다 —
79
+ 768px 태블릿에서 카드 안쪽이 555px 이라(1108px 창에서도 707px) 일곱 칸이 850px 를 우겨
80
+ 목·금·토가 조상의 `overflow: hidden` 에 잘려 나갔다(말귀 2026-09-22 실측). 창 기준
81
+ 중단점으로는 못 막는 자리라 상한 자체를 걷는다 — 넓은 화면에서는 `1fr` 이 알아서 118 을 넘는다.
82
+ 칸을 컨테이너로 삼아, 좁아지면 방문 이름표를 '몇 건' 으로 접는다(아래 컨테이너 질의). */
77
83
  .calendar-cell {
78
- min-width: 118px;
84
+ min-width: 0;
85
+ container-type: inline-size;
79
86
  min-height: 118px;
80
87
  border-radius: var(--radius-s);
81
88
  padding: 8px;
@@ -126,6 +133,28 @@
126
133
  gap: 4px;
127
134
  }
128
135
 
136
+ /* 몇 건인지 — **좁은 칸에서만** 선다. 넓은 칸에서는 이름표가 그 자리를 한다. */
137
+ .calendar-cell__visit-count {
138
+ display: none;
139
+ align-items: center;
140
+ gap: 4px;
141
+ color: var(--color-main);
142
+ }
143
+ .calendar-cell__visit-dot {
144
+ width: 6px;
145
+ height: 6px;
146
+ border-radius: 50%;
147
+ background: currentColor;
148
+ flex-shrink: 0;
149
+ }
150
+ /* 이름표 한 줄("13시 33분 · 지한")이 담기려면 100px 은 있어야 한다 — 그 밑에서는 접는다.
151
+ 창이 아니라 **칸 제 폭**으로 정한다: 같은 창에서도 담긴 카드가 좁으면 칸이 좁아진다.
152
+ 재는 값은 칸의 **안쪽**(여백 8px씩 뺀 값)이라, 118px 짜리 칸이면 102 로 이 문턱을 넘는다. */
153
+ @container (max-width: 96px) {
154
+ .calendar-cell__badges { display: none; }
155
+ .calendar-cell__visit-count { display: inline-flex; }
156
+ }
157
+
129
158
  .calendar-visit-badge {
130
159
  color: var(--color-main);
131
160
  background: var(--color-main-a10);
@@ -235,10 +264,8 @@
235
264
  justify-content: center;
236
265
  }
237
266
 
238
- /* 셀 min-width 118px × 7 + gap = 850px라, 좁은 화면에선 격자가 줄어들지 못하고
239
- 그대로 잘렸다(320px에서 620px가 화면 밖 — 조상이 hidden이라 스크롤도 안 돼
240
- 토요일 쪽 요일이 **아예 보이지 않았다**). 좁을 땐 상한을 풀어 1fr로 나눠 갖게 한다. */
241
- .calendar-cell,
267
+ /* 칸의 min-width 는 이제 어느 폭에서도 0 이다(위 `.calendar-cell` 주석) — 여기서는
268
+ 요일 머리만 함께 풀고, 좁은 칸의 높이·여백을 줄인다. */
242
269
  .calendar-day-header {
243
270
  min-width: 0;
244
271
  }
@@ -83,7 +83,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
83
83
  dragVisitId.current = null;
84
84
  }
85
85
  setDragOverDate(null);
86
- } : undefined, children: [_jsx("div", { className: "calendar-cell__number body-3", children: day }), _jsx("div", { className: "calendar-cell__badges", children: dayVisits.map((v, vi) => (_jsxs("div", { draggable: !!onVisitMove, className: `calendar-visit-badge${v.status === 'noVisit' ? ' calendar-visit-badge--no-visit' : v.status === 'scheduled' ? ' calendar-visit-badge--scheduled' : ''}${onVisitClick ? ' calendar-visit-badge--clickable' : ''}${onVisitMove ? ' calendar-visit-badge--draggable' : ''}`, onDragStart: onVisitMove ? (e) => {
86
+ } : undefined, children: [_jsx("div", { className: "calendar-cell__number body-3", children: day }), dayVisits.length > 0 && (_jsxs("span", { className: "calendar-cell__visit-count caption-1", "aria-label": t('calendar.visitCount', { count: dayVisits.length, defaultValue: '방문 {{count}}건' }), children: [_jsx("span", { className: "calendar-cell__visit-dot", "aria-hidden": "true" }), dayVisits.length] })), _jsx("div", { className: "calendar-cell__badges", children: dayVisits.map((v, vi) => (_jsxs("div", { draggable: !!onVisitMove, className: `calendar-visit-badge${v.status === 'noVisit' ? ' calendar-visit-badge--no-visit' : v.status === 'scheduled' ? ' calendar-visit-badge--scheduled' : ''}${onVisitClick ? ' calendar-visit-badge--clickable' : ''}${onVisitMove ? ' calendar-visit-badge--draggable' : ''}`, onDragStart: onVisitMove ? (e) => {
87
87
  dragVisitId.current = v.id;
88
88
  e.dataTransfer.effectAllowed = 'move';
89
89
  } : undefined, onDragEnd: onVisitMove ? () => {
@@ -86,8 +86,14 @@
86
86
  }
87
87
 
88
88
  @media (max-width: 767px) {
89
+ /* **세로로 세울 때는 줄바꿈을 끈다.** 기본 규칙의 `flex-wrap: wrap` 은 가로(row)에서
90
+ 오른쪽 묶음을 아랫줄로 내리려고 둔 것인데, 여기서 방향만 세로로 바꾸면 그 줄바꿈이
91
+ **오른쪽 새 칸**을 만든다 — 머리 높이가 정해지는 자리에서 셋째인 설명(`__disc`)이
92
+ 아랫줄이 아니라 옆 칸으로 빠져 카드 밖 141px 까지 나갔다(말귀 375px 문장훈련 분석
93
+ 갈래 카드 실측 2026-09-22). 세로에서는 줄을 바꿀 일이 없다. */
89
94
  .manager-section__head {
90
95
  flex-direction: column;
96
+ flex-wrap: nowrap;
91
97
  align-items: flex-start;
92
98
  gap: var(--spacing-s);
93
99
  }
@@ -12,10 +12,13 @@
12
12
  빈 화면이 위에 붙어 아래가 휑하던 자리다. 자리가 빠듯하면 auto 가 0 이 되어 예전과 같다. */
13
13
  margin-block: auto;
14
14
  }
15
- /* 빈 화면 아이콘 — 40 은 제목(h4) 옆에서 작았다(사용자 지시 2026-09-21).
16
- **그림 크기는 `Icon size` 가 정한다**(EmptyState.tsx) — 여기 폭·높이는 그 그림이 앉는 자리다.
17
- 0.183.0 은 이 자리만 56 으로 키우고 `size` 를 안 줘서 상자는 56, 그림은 20 이었다. */
18
- .empty-state__icon { width: 48px; height: 48px; color: var(--color-lightGray); display: inline-flex; align-items: center; justify-content: center; }
15
+ /* 빈 화면 아이콘 — **크기를 여기서 박지 않는다.** 크기는 `Icon size` 한 곳이 정하고(48,
16
+ EmptyState.tsx · ErrorState.tsx) 이 상자는 그 그림에 맞춰 줄어든다.
17
+ 상자와 그림이 따로 크기를 가지면 한쪽만 바뀔 때 조용히 어긋난다 — 실제로 두 번 어긋났다:
18
+ 0.182.0 은 상자 40 · 그림 20(그림 둘레에 10px 빈 테가 생겨 제목과의 간격까지 벌어졌다),
19
+ 0.183.0 은 상자 56 · 그림 20. 둘 다 화면에서 잡혔다(사용자 지시 2026-09-21 "아이콘 크기
20
+ 지정하고 있는 것은 걷는다"). inline-flex 만 남기는 것은 글줄 기준선 아래 빈틈을 없애려는 것이다. */
21
+ .empty-state__icon { color: var(--color-lightGray); display: inline-flex; }
19
22
  /* 제목과 본문은 **같은 색** 이다(사용자 지시 2026-09-21) — 전에는 제목이 한 단계 옅어(lightGray)
20
23
  제목이 본문보다 뒤로 물러나 보였다. 위계는 글자 등급(h4 · body-3)이 준다.
21
24
  같은 색으로 모을 때 gray 를 고른 것은 lightGray 가 흰 지면에서 AA(4.5:1)에 못 미치기 때문이다. */
@@ -9,7 +9,7 @@ import { usePendingPress } from '../hooks/usePendingPress';
9
9
  export default function ErrorState({ message, hint, icon = 'triangle-alert', onRetry, retryLabel, className }) {
10
10
  const { t } = useTranslation();
11
11
  const { pending, press } = usePendingPress(onRetry);
12
- return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "error-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
12
+ return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, size: 48, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "error-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
13
13
  // type="button" — 없으면 폼 안에서 다시 시도를 누르는 순간 폼이 제출된다
14
14
  type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: press, disabled: pending, "aria-busy": pending || undefined, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
15
15
  }
@@ -78,8 +78,11 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
78
78
  /* 아무것도 안 골랐으면 빈 값 — undefined 를 넘기면 SegmentedControl 의 기본값(첫 보기)이 걸려, 상태는
79
79
  비었는데 첫 보기가 골라진 것처럼 보였다(필터는 걸리지 않았는데 걸린 듯). */
80
80
  value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label),
81
- /* 옆 Dropdown·TextField(40)와 같은 높이 — 0.159.0 에서 고르개 여백이 빠져 겉높이가 곧 size 다(32 면 8px 낮아진다) */
82
- size: 40 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
81
+ /* 같은 패널의 항목 단추(size "keyboard" = 32)와 같은 높이다(사용자 지시 2026-09-22).
82
+ 전에는 40 이었는데, 그 까닭으로 적힌 "옆 Dropdown·TextField(40)와 맞춘다" 는
83
+ 실제와 달랐다 — 세그먼트를 쓰는 패널(단어표 '표 설정')에는 Dropdown 도 TextField 도
84
+ 없고 32 짜리 항목 단추만 있어, 한 줄에 32 와 40 이 섞여 세그먼트만 커 보였다. */
85
+ size: 32 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
83
86
  ...(section.allOption ? [{ value: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체'), disabled: section.allOptionDisabled }] : []),
84
87
  ...items.map(item => {
85
88
  const value = typeof item === 'string' ? item : item.value;
@@ -19,7 +19,7 @@ export declare function BrandIdentity({ appMode }: {
19
19
  *
20
20
  * @public
21
21
  */
22
- export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer }: {
22
+ export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer }: {
23
23
  activeKey?: string | null;
24
24
  onChange?: (key?: string) => void;
25
25
  /** 지금 모드의 내비 항목. 데스크톱은 SideNav, 모바일은 BottomNav 가 이 목록을 그린다. */
@@ -37,6 +37,8 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
37
37
  onAccount?: () => void;
38
38
  onSetting?: () => void;
39
39
  onHelp?: () => void;
40
+ /** 주면 도움말과 계정 **사이** 에 '버그신고' 를 그린다 — 냅 바닥과 모바일 '…' 메뉴 둘 다. */
41
+ onFeedback?: () => void;
40
42
  appMode?: 'malgwi' | 'manager';
41
43
  onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
42
44
  /**
@@ -42,7 +42,7 @@ export function BrandIdentity({ appMode }) {
42
42
  *
43
43
  * @public
44
44
  */
45
- export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer }) {
45
+ export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer }) {
46
46
  const { t } = useTranslation();
47
47
  const isMobile = useMediaQuery("(max-width: 767px)");
48
48
  /* **모바일 하단 내비에 SideNav 항목을 옮겨 적는다.** 예전에는 `SideNavItem` 을 `NavItem` 으로
@@ -91,10 +91,12 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
91
91
  list.push({ key: 'settings', icon: 'settings', label: t('nav.settings', '설정'), onClick: onSetting });
92
92
  if (onHelp)
93
93
  list.push({ key: 'help', icon: 'message-circle-question-mark', label: t('nav.help', '도움말'), onClick: onHelp });
94
+ if (onFeedback)
95
+ list.push({ key: 'feedback', icon: 'bug', label: t('nav.feedback', '버그신고'), onClick: onFeedback });
94
96
  if (onAccount)
95
97
  list.push({ key: 'account', icon: 'user-round', label: t('nav.account', '계정'), onClick: onAccount });
96
98
  return list;
97
- }, [t, onDebug, onNotification, onSetting, onHelp, onAccount, unreadCount]);
99
+ }, [t, onDebug, onNotification, onSetting, onHelp, onFeedback, onAccount, unreadCount]);
98
100
  // 헤더 햄버거 — SideNav 푸터에 해당하는 유틸(알림·설정·도움말·계정·디버그)을 띄운다.
99
101
  // 데스크톱에선 SideNav 푸터에 늘 보이지만 모바일에선 그 자리가 없어, 같은 항목을
100
102
  // 하단 더보기(…)와 동일한 구성(bottomMoreItems)으로 재사용한다.
@@ -116,9 +118,9 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
116
118
  onClick: () => { it.onClick?.(); closeUtilMenu(); },
117
119
  })),
118
120
  }], [bottomMoreItems, activeKey, closeUtilMenu]);
119
- return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: onNotification && unreadCount && unreadCount > 0
121
+ return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", useBorder: false, icon: 'menu', ariaLabel: onNotification && unreadCount && unreadCount > 0
120
122
  ? t('nav.badgeLabel', { label: t('header.menuOpen', '메뉴 열기'), count: unreadCount, defaultValue: '{{label}}, {{count}}개' })
121
- : t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), !!onNotification && !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true })), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { scrollContainer: scrollContainer, items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, scrollContainer: scrollContainer, appMode: appMode, onAppModeChange: onAppModeChange })] }));
123
+ : t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), !!onNotification && !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true })), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { scrollContainer: scrollContainer, items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, onFeedback: onFeedback, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, scrollContainer: scrollContainer, appMode: appMode, onAppModeChange: onAppModeChange })] }));
122
124
  }
123
125
  /**
124
126
  * `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
@@ -20,9 +20,13 @@
20
20
  }
21
21
  .page-title__action {
22
22
  margin-left: 0;
23
- /* 세로로 내려오면 제목과 같은 선에서 시작하게 한다 — 우측에 붙여 두면
24
- 제목과 어긋나 화면 오른쪽에 홀로 떠 있는 것처럼 보인다. */
25
- align-self: flex-start;
23
+ /* 세로로 내려오면 **가운데**에 둔다(사용자 지시 2026-09-22).
24
+ 한때는 `flex-start` 였다 — 오른쪽에 붙여 두면 제목과 어긋나 화면 오른쪽에 홀로 떠
25
+ 있는 것처럼 보였기 때문이다. 그 진단은 맞았지만 답이 한 칸 모자랐다: 왼쪽에 붙이면
26
+ 제목 줄과는 맞아도, 여기 서는 것(세그먼트 고르개·단추 하나)은 제목처럼 줄글이 아니라
27
+ **덩어리**라 한쪽에 몰린 것으로 읽힌다. 가운데가 그 둘 사이다.
28
+ 실측(375px, 제목 줄 335px · 고르개 160px): 왼쪽 20~180 → 가운데 107~267. */
29
+ align-self: center;
26
30
  }
27
31
  }
28
32
 
@@ -63,9 +63,12 @@
63
63
  gap: var(--spacing-xs);
64
64
  }
65
65
  .popup-title { padding: var(--spacing-l); display: block; text-align: center; flex-shrink: 0; }
66
- .popup-header { padding: var(--spacing-l) var(--spacing-l) 0; display: flex; align-items: center; gap: var(--spacing-s); flex-shrink: 0; }
66
+ /* 좁아지면 **오른쪽 묶음이 아랫줄로 내려간다**. 예전에는 묶음이 `flex-shrink: 0` 이라 제 속 너비를
67
+ 끝까지 우기고 제목 칸만 0 까지 눌려, 좁은 화면에서 제목이 한 글자씩 세로로 쏟아졌다
68
+ (말귀 2026-09-22 실측). 제목에 밑바닥(220px)을 줘 그 아래로는 줄을 바꾸게 한다. */
69
+ .popup-header { padding: var(--spacing-l) var(--spacing-l) 0; display: flex; align-items: center; flex-wrap: wrap; gap: var(--spacing-s); flex-shrink: 0; }
67
70
  .popup-header--with-subtitle { align-items: flex-start; }
68
- .popup-header-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
71
+ .popup-header-main { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
69
72
  .popup-title--left { padding: 0; text-align: left; flex: 1; min-width: 0; }
70
73
  .popup-header-main .popup-title--left { flex: none; }
71
74
  .popup-header .popup-title--left { margin: 0; }
@@ -73,7 +76,9 @@
73
76
  /* 머리 오른쪽 고르개(headerSegment) — 닫기·headerActions 앞. 좁으면 제목 아래로 줄을 바꾸고
74
77
  제목을 밀어내지 않는다(제목 칸이 flex:1 로 먼저 줄어든다). */
75
78
  .popup-header-segment { flex-shrink: 0; }
76
- .popup-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
79
+ /* 줄이 바뀌면 오른쪽 끝에 선다. 안에서도 한 번 더 줄을 바꿀 수 있어야 한다 — 앱이 고르개처럼
80
+ 넓은 것을 얹는 자리다. 닫기(✕)는 어느 경우에도 이 묶음 안에 있어 손이 닿는다. */
81
+ .popup-header-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin-left: auto; min-width: 0; }
77
82
  .popup-content {
78
83
  padding: 20px 20px 0;
79
84
  flex: 1;
@@ -91,7 +96,9 @@
91
96
  }
92
97
  /* 세로는 가운데 — 안 적어 두면 stretch 라, 키가 다른 것(보조 글월 + 단추)을 나란히 두면 늘어난다.
93
98
  소비자 셋이 이 자리를 각자 `align-items: center` 로 칠하고 있었다(2026-09-17 말귀 전수). */
94
- .popup-footer { padding: 0 20px 20px; display:flex; gap:8px; justify-content:center; align-items:center }
99
+ /* 좁으면 **줄을 바꾼다.** 안 바꾸면 단추 셋(120×3 + 사이 = 408px)이 353px 바닥에 그대로 서서
100
+ 마지막 단추의 오른쪽 55px 이 잘렸다 — 잘린 쪽은 눌리지도 않는다(말귀 375px 채점 창 실측). */
101
+ .popup-footer { padding: 0 20px 20px; display:flex; flex-wrap: wrap; gap:8px; justify-content:center; align-items:center }
95
102
  /* 바닥 배치(footerLayout) */
96
103
  .popup-footer--fill > * { flex: 1 }
97
104
  .popup-footer--between {
@@ -99,8 +106,10 @@
99
106
  padding-left: var(--spacing-xl);
100
107
  padding-right: var(--spacing-xl);
101
108
  }
102
- /* 표준 buttons 배열만 폭/정렬 제약 — 커스텀 footer는 자유 배치 */
103
- .popup-footer--buttons > * { max-width: 200px; flex: 1; }
109
+ /* 표준 buttons 배열만 폭/정렬 제약 — 커스텀 footer는 자유 배치.
110
+ `flex: 1` 의 기본 바닥(min-width: auto)은 글자 길이만큼 버틴다 — 0 으로 내려 줄이 바뀌기 전에
111
+ 먼저 줄어들게 하고, 그래도 모자라면 위의 `flex-wrap` 이 아랫줄로 내린다. */
112
+ .popup-footer--buttons > * { max-width: 200px; flex: 1 1 auto; min-width: 0; }
104
113
  .popup--closing { opacity: 0; transition: opacity 260ms ease }
105
114
 
106
115
  @media (max-width: 767px) {
@@ -28,11 +28,15 @@
28
28
  width: 100%;
29
29
  }
30
30
 
31
+ /* **줄어들 수 있어야 한다.** flex 항목의 min-width 기본값은 auto 라, 설명(`disc`)이 길면
32
+ 그 자연 폭만큼 버텨 카드 밖으로 밀려 나갔다(말귀 375px 분석 창 갈래 카드 실측 2026-09-22).
33
+ 글은 줄을 바꾸면 되는 것이라 바닥을 0 으로 내린다. */
31
34
  .section__title-row {
32
35
  display: flex;
33
36
  align-items: flex-start;
34
37
  gap: var(--spacing-s);
35
38
  flex: 1;
39
+ min-width: 0;
36
40
  }
37
41
 
38
42
  .section__icon {
@@ -52,6 +56,8 @@
52
56
  display: flex;
53
57
  flex-direction: column;
54
58
  gap: 4px;
59
+ /* 위와 같은 까닭 — 제목·설명이 담긴 칸도 줄어들 수 있어야 한다 */
60
+ min-width: 0;
55
61
  }
56
62
 
57
63
  .section__title {
@@ -23,6 +23,8 @@ export interface SideNavProps {
23
23
  onAccount?: () => void;
24
24
  onSetting?: () => void;
25
25
  onHelp?: () => void;
26
+ /** 주면 도움말과 계정 사이에 '버그신고' 를 그린다. 안 주면 그 자리가 없다(`onDebug` 와 같은 결). */
27
+ onFeedback?: () => void;
26
28
  /** 현재 앱 모드 ('malgwi' | 'manager') */
27
29
  appMode?: 'malgwi' | 'manager';
28
30
  /** 앱 모드 전환 콜백 */
@@ -14,7 +14,7 @@ function nameWithCount(t, label, count) {
14
14
  }
15
15
  function SideNavFooter(props) {
16
16
  const { t } = useTranslation();
17
- const { activeKey, unreadCount, onDebug, onNotification, onSetting, onHelp, onAccount } = props || {};
17
+ const { activeKey, unreadCount, onDebug, onNotification, onSetting, onHelp, onFeedback, onAccount } = props || {};
18
18
  const variant = 'default';
19
19
  /* **핸들러를 준 것만 그린다.**
20
20
  예전에는 디버그만 이 규칙을 따르고 나머지 넷은 핸들러와 무관하게 늘 그렸다.
@@ -22,11 +22,11 @@ function SideNavFooter(props) {
22
22
  넷 서 있었다 — 눌러도 아무 일이 없으니 사람은 고장으로 읽고, 낭독기에는
23
23
  '알림 단추' 라고 읽히는데 실제로는 아무 데도 못 간다.
24
24
  **없는 단추가 죽은 단추보다 낫다.** */
25
- const 그릴것 = onDebug || onNotification || onSetting || onHelp || onAccount;
25
+ const 그릴것 = onDebug || onNotification || onSetting || onHelp || onFeedback || onAccount;
26
26
  // 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
27
27
  if (!그릴것)
28
28
  return null;
29
- return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
29
+ return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onFeedback && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bug", ariaLabel: t('nav.feedback', '버그신고'), onClick: onFeedback, selected: activeKey === 'feedback' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
30
30
  }
31
31
  function SideNavButton(props) {
32
32
  const { t } = useTranslation();
@@ -58,7 +58,7 @@ function SideNavButton(props) {
58
58
  /** @public */
59
59
  export default function SideNav(props) {
60
60
  const { t } = useTranslation();
61
- const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer } = props || {};
61
+ const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer } = props || {};
62
62
  /* 모드를 안 주면 말귀로 본다 — Header(브랜드)·BottomNav 와 같게. 전에는 여기만 매니저로 봐 같은 화면에서
63
63
  데스크톱은 '말귀로 이동', 모바일은 '말귀 매니저' 로 서로 반대를 가리켰다. */
64
64
  const isManager = appMode === 'manager';
@@ -118,5 +118,5 @@ export default function SideNav(props) {
118
118
  key: '__mode-switch__',
119
119
  label: isManager ? t('nav.goToMalgwi', '말귀로 이동') : t('nav.goToManager', '말귀매니저로 이동'),
120
120
  icon: isManager ? 'arrow-left' : 'arrow-right',
121
- }, onClick: handleModeSwitch }))] }), _jsx(SideNavFooter, { activeKey: activeKey, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp })] }));
121
+ }, onClick: handleModeSwitch }))] }), _jsx(SideNavFooter, { activeKey: activeKey, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, onFeedback: onFeedback })] }));
122
122
  }
@@ -38,6 +38,27 @@
38
38
  gap: var(--spacing-s);
39
39
  }
40
40
 
41
+ /* **안쪽 묶음이 줄 전체로 늘어나면 `justify-content` 가 먹을 여지가 없다.**
42
+ 우측 묶음의 한 자식(예: 기간 조작 `.manager-section-controls`)이 제 안에서 줄을 바꾸면,
43
+ 그 자식의 폭이 줄 전체가 되어(375px 에서 335px) 안의 항목들이 **왼쪽부터** 깔렸다 —
44
+ 우측 묶음은 오른쪽에 붙었는데 그 안은 왼쪽에 붙는 꼴이다(말귀 실측 2026-09-22).
45
+ 늘어나는 것 자체는 막지 않는다(막으면 좁은 화면에서 그 묶음이 제 줄을 못 쓴다).
46
+ 대신 **늘어나도 안쪽이 오른쪽에 서게** 한다 — 원래 오른쪽 것이던 자리다. */
47
+ .toolbar__row-end > * {
48
+ justify-content: flex-end;
49
+ }
50
+
51
+ /* 페이지 크기 + 쪽수 — 한 덩어리로 움직인다. 안에서는 줄을 바꾸지 않는다(`nowrap`):
52
+ 둘이 갈라지면 "50개씩 보기" 만 남고 쪽수가 다음 줄로 떨어져, 같은 것을 다루는 짝이
53
+ 따로 노는 것처럼 보였다(말귀 실측 2026-09-22). 좁으면 이 덩어리가 통째로 내려간다. */
54
+ .toolbar__pager {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: var(--spacing-s);
58
+ flex-wrap: nowrap;
59
+ flex-shrink: 0;
60
+ }
61
+
41
62
  .toolbar__count {
42
63
  flex-shrink: 0;
43
64
  margin: 0;
@@ -18,5 +18,5 @@ export default function Toolbar({ count, search, segment, filters, pageSize, pag
18
18
  /* placeholder는 접근 가능한 이름이 아니다 — 입력이 시작되면 사라지고
19
19
  화면낭독기 지원도 제각각이라, 같은 문구를 aria-label로도 준다.
20
20
  (툴바 검색은 시각적 라벨이 따로 없는 자리라 이게 유일한 이름이 된다.) */
21
- ariaLabel: search.placeholder ?? t('common.search', '검색'), clearable: true })), filters?.map((f, i) => (_jsx(Dropdown, { size: 40, options: f.options, value: f.value, onChange: f.onChange, ariaLabel: f.ariaLabel }, i))), pageSize && _jsx(Dropdown, { size: 40, options: pageSize.options, value: pageSize.value, onChange: pageSize.onChange, ariaLabel: pageSize.ariaLabel ?? t('common.pageSizeSelect', '페이지당 표시 개수') }), pagination && (_jsx(Pagination, { page: pagination.page, totalPages: pagination.total, onChange: pagination.onChange, prevLabel: pagination.prevLabel, nextLabel: pagination.nextLabel })), actions] }) }))] }));
21
+ ariaLabel: search.placeholder ?? t('common.search', '검색'), clearable: true })), filters?.map((f, i) => (_jsx(Dropdown, { size: 40, options: f.options, value: f.value, onChange: f.onChange, ariaLabel: f.ariaLabel }, i))), (pageSize || pagination) && (_jsxs("div", { className: "toolbar__pager", children: [pageSize && _jsx(Dropdown, { size: 40, options: pageSize.options, value: pageSize.value, onChange: pageSize.onChange, ariaLabel: pageSize.ariaLabel ?? t('common.pageSizeSelect', '페이지당 표시 개수') }), pagination && (_jsx(Pagination, { page: pagination.page, totalPages: pagination.total, onChange: pagination.onChange, prevLabel: pagination.prevLabel, nextLabel: pagination.nextLabel }))] })), actions] }) }))] }));
22
22
  }
@@ -905,7 +905,9 @@ function TrainingPagePopupInner(props) {
905
905
  아무 데도 닿지 않았다** — 낭독기는 이것을 그냥 대화상자로 읽고 가상 커서가
906
906
  뒤 화면으로 새어 나간다(Tab 가둠은 `useFocusTrap` 이 따로 하고 있었다).
907
907
  둘을 한 요소에 모은다. */
908
- _jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
908
+ _jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount,
909
+ /* 결과 창이 뜨면 바닥의 오디오 미터를 끈다 — 결과를 보는 동안 떠 있을 까닭이 없다 */
910
+ resultOpen: Boolean(resultOverlay && !resultOverlay.isEmpty) })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
909
911
  score: resultOverlay.score,
910
912
  results: (resultOverlay.results || resultOverlay.answers || []),
911
913
  contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
@@ -43,3 +43,17 @@
43
43
  }
44
44
 
45
45
  .pp-bottom__tooltip-trigger { display: inline-block; }
46
+
47
+ /* 폰에서 토글 단추 위에 떠서 보이는 미터 — 바닥 줄에 나란히 두면 미터 두 줄(222px)이
48
+ '다음 문제'(150px)를 화면 밖으로 밀어낸다(말귀 375px 실측: 보이는 폭 5px, 바닥이
49
+ overflow:visible 이라 굴려 꺼낼 수도 없었다). 떠 있는 판에서는 두 줄을 세로로 쌓는다. */
50
+ .pp-bottom__meters--floating {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: var(--spacing-xs);
54
+ }
55
+ /* 토글 단추 자리 — 떠 있는 판의 기준점이라 흐름에서 제 크기를 지킨다 */
56
+ .pp-bottom__meter-trigger {
57
+ display: inline-flex;
58
+ flex-shrink: 0;
59
+ }
@@ -6,10 +6,12 @@ export type TrainingPagePopupBottomProps = {
6
6
  onNext?: () => void;
7
7
  onResult?: () => void;
8
8
  stepIndex?: number;
9
+ /** 결과 창이 떠 있는가 — 뜨는 순간 오디오 미터를 끈다(사용자 지시 2026-09-22). */
10
+ resultOpen?: boolean;
9
11
  stepCount?: number;
10
12
  pageIndex?: number;
11
13
  /** 쪽 수. 0(쪽이 없음)이어도 '결과보기'(완료) 단추는 보이고, '다음 문제'(쪽 이동)만 숨는다. 기본 1. */
12
14
  pageCount?: number;
13
15
  };
14
16
  /** @public */
15
- export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, }: TrainingPagePopupBottomProps): import("react").JSX.Element;
17
+ export default function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): import("react").JSX.Element;