@ihabdevteam/core 0.184.0 → 0.186.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 (41) hide show
  1. package/CHANGELOG.md +202 -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 +180 -180
  7. package/dist/ihabdevteam-core.d.ts +26 -6
  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/FilterPanel.js +5 -2
  13. package/dist/packages/core/src/components/Header.d.ts +3 -1
  14. package/dist/packages/core/src/components/Header.js +6 -4
  15. package/dist/packages/core/src/components/PageTitle.css +7 -3
  16. package/dist/packages/core/src/components/Popup/Popup.css +15 -6
  17. package/dist/packages/core/src/components/Section.css +6 -0
  18. package/dist/packages/core/src/components/SegmentedControl.d.ts +5 -0
  19. package/dist/packages/core/src/components/SideNav.d.ts +2 -0
  20. package/dist/packages/core/src/components/SideNav.js +5 -5
  21. package/dist/packages/core/src/components/Table.d.ts +6 -3
  22. package/dist/packages/core/src/components/Table.js +44 -8
  23. package/dist/packages/core/src/components/Toolbar.css +21 -0
  24. package/dist/packages/core/src/components/Toolbar.js +1 -1
  25. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
  26. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.css +14 -0
  27. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +3 -1
  28. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +30 -4
  29. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +12 -0
  30. package/dist/packages/core/src/components/Training/WordTablePopup.css +18 -0
  31. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.css +25 -1
  32. package/dist/packages/core/src/components/WordTable.css +19 -0
  33. package/dist/packages/core/src/components/WordTable.js +21 -2
  34. package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
  35. package/dist/packages/core/src/components/useFitScale.js +17 -1
  36. package/dist/packages/core/src/i18n/locales/en.json +4 -2
  37. package/dist/packages/core/src/i18n/locales/ko.json +4 -2
  38. package/dist/packages/core/src/version.d.ts +1 -1
  39. package/dist/packages/core/src/version.js +1 -1
  40. package/dist/utils.cjs.js +1 -1
  41. package/package.json +1 -1
@@ -809,6 +809,12 @@ export declare interface ButtonProps {
809
809
  * 크기. 숫자는 CSS 의 크기 사다리(.button24 … .button72)와 짝을 이룬다 — **사다리에
810
810
  * 없는 숫자를 넣으면 그 버튼은 크기·모서리 규칙을 하나도 못 받는다.**
811
811
  *
812
+ * **숫자 size 는 좁은 화면에서 한 급씩 줄어든다.** 1024px 이하에서 56→50 · 48→44 · 40→36 · 32→30,
813
+ * 768px 이하에서 56→46 · 48→40 · 40→34 · **32→28** 이다. `'Keyboard'`(32px)·`'Word'`(72px) 는
814
+ * **줄어들지 않는다** — 그 둘에는 반응형 규칙이 없다. 그래서 폰에서 `size={32}` 와 `size="Keyboard"` 를
815
+ * 한 줄에 세우면 28 과 32 로 4px 어긋난다(말귀 '표 설정' 패널 실측 2026-09-22). 나란히 세워야 하면
816
+ * 양쪽 다 숫자 size 를 쓰거나 양쪽 다 Keyboard 를 쓴다.
817
+ *
812
818
  * 목록이 사실과 두 군데 어긋나 있었다. 72 는 CSS 에 있는데 목록에 없었고, 52 는
813
819
  * 목록에도 CSS 에도 없는데 BottomNav 가 쓰고 있었다(그쪽은 자체 클래스가 !important
814
820
  * 로 크기를 다시 잡아 겉으로는 멀쩡했다). 둘 다 사실에 맞춘다 — 52 는 CSS 에 칸을
@@ -1600,7 +1606,7 @@ export declare function copyText(text: string): Promise<boolean>;
1600
1606
  *
1601
1607
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1602
1608
  */
1603
- export declare const CORE_VERSION = "0.184.0";
1609
+ export declare const CORE_VERSION = "0.186.0";
1604
1610
 
1605
1611
  /** @public */
1606
1612
  declare const coreDefault: Record<string, unknown>;
@@ -2734,7 +2740,7 @@ export declare function hasStructureContrast(A: Decomp, B: Decomp): boolean;
2734
2740
  *
2735
2741
  * @public
2736
2742
  */
2737
- export declare function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer }: {
2743
+ export declare function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, onFeedback, appMode, onAppModeChange, scrollContainer }: {
2738
2744
  activeKey?: string | null;
2739
2745
  onChange?: (key?: string) => void;
2740
2746
  /** 지금 모드의 내비 항목. 데스크톱은 SideNav, 모바일은 BottomNav 가 이 목록을 그린다. */
@@ -2752,6 +2758,8 @@ export declare function Header({ activeKey, onChange, items, malgwiItems: _malgw
2752
2758
  onAccount?: () => void;
2753
2759
  onSetting?: () => void;
2754
2760
  onHelp?: () => void;
2761
+ /** 주면 도움말과 계정 **사이** 에 '버그신고' 를 그린다 — 냅 바닥과 모바일 '…' 메뉴 둘 다. */
2762
+ onFeedback?: () => void;
2755
2763
  appMode?: 'malgwi' | 'manager';
2756
2764
  onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
2757
2765
  /**
@@ -5878,6 +5886,11 @@ export declare interface SegmentedControlProps {
5878
5886
  /** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). **기본 40.** **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
5879
5887
  * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다.
5880
5888
  *
5889
+ * **다만 768px 이하에서는 숫자 size 가 한 급 줄어든다**(Button 의 반응형 사다리: 48→40 · 40→34 · **32→28**; 1024px 이하에서는 32→30).
5890
+ * 고르개는 그 Button 을 그대로 쓰므로 함께 줄어든다. 그래서 폰에서 `size={32}` 는 **28px** 로 선다.
5891
+ * 같은 줄에 `size="Keyboard"`(32px 고정, 줄어들지 않는다) 단추를 세우면 폰에서만 4px 어긋난다 —
5892
+ * 나란히 세워야 하면 양쪽 다 숫자 size 를 쓰거나 양쪽 다 Keyboard 를 쓴다(말귀 '표 설정' 패널 실측 2026-09-22).
5893
+ *
5881
5894
  * 기본이 48 이던 것을 40 으로 내렸다(사용자 지시 2026-09-21). 코어가 제 안에서 세우는 고르개는 이미 **전부 40**
5882
5895
  * 이었고(`Toolbar`·`FilterPanel`·`BarChart`·`PanelRow`…), 옆에 서는 `Dropdown`·`TextField` 도 40 이다 —
5883
5896
  * 기본값만 혼자 48 이라 안 주고 쓴 자리가 한 급 크게 섰다. */
@@ -6082,6 +6095,8 @@ export declare interface SideNavProps {
6082
6095
  onAccount?: () => void;
6083
6096
  onSetting?: () => void;
6084
6097
  onHelp?: () => void;
6098
+ /** 주면 도움말과 계정 사이에 '버그신고' 를 그린다. 안 주면 그 자리가 없다(`onDebug` 와 같은 결). */
6099
+ onFeedback?: () => void;
6085
6100
  /** 현재 앱 모드 ('malgwi' | 'manager') */
6086
6101
  appMode?: 'malgwi' | 'manager';
6087
6102
  /** 앱 모드 전환 콜백 */
@@ -6850,9 +6865,12 @@ export declare type TableProps<T extends {
6850
6865
  draggingId?: string | number | null;
6851
6866
  gridTemplateColumns?: string;
6852
6867
  /**
6853
- * 좁은 화면(767px 이하)에서 쓸 열 틀. 주면 그 폭에서는 이 틀을 쓰고 **끌어 둔 폭(저장값)은 무시**한다 —
6854
- * 폭 조절 손잡이와 '칸 너비 초기화' 단추도 숨겨, 좁은 화면에서 끈 값이 넓은 화면의 저장값을 덮지 않게 한다.
6855
- * 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다. 미지정이면 좁은 화면도 넓은 화면과 같다(기본).
6868
+ * 좁은 화면(767px 이하)에서 쓸 열 틀. 주면 그 폭에서는 이 틀을 쓴다.
6869
+ *
6870
+ * **안 줘도 좁은 화면에서는 끌어 둔 폭(저장값)을 안 쓰고, 손잡이와 '칸 너비 초기화' 단추도 숨긴다** —
6871
+ * 이 prop 과 무관하게 늘 그렇다. 그래서 안 주면 좁은 화면에서는 `gridTemplateColumns` 가 그대로 서고,
6872
+ * 그 틀의 `minmax(…px, …fr)` 밑값이 지켜지며 넘치는 만큼은 표가 제 가로 스크롤로 보여 준다.
6873
+ * 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다.
6856
6874
  *
6857
6875
  * 열 틀은 인라인 CSS 변수로 들어가므로 앱 CSS 로는 `!important` 없이 못 이긴다 — 그래서 prop 이다.
6858
6876
  * 숨길 칸은 `renderRow` 의 `className`·`fields[].className` 으로 앱이 가린다.
@@ -7898,7 +7916,7 @@ export declare interface TrainingItemResult {
7898
7916
  export declare const TrainingPagePopup: TrainingPagePopupComponent;
7899
7917
 
7900
7918
  /** @public */
7901
- export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, }: TrainingPagePopupBottomProps): JSX.Element;
7919
+ export declare function TrainingPagePopupBottom({ useNoiseSettingBtn, onSettings, onNext, onResult, stepIndex, stepCount, pageIndex, pageCount, resultOpen, }: TrainingPagePopupBottomProps): JSX.Element;
7902
7920
 
7903
7921
  /** @public */
7904
7922
  export declare type TrainingPagePopupBottomProps = {
@@ -7907,6 +7925,8 @@ export declare type TrainingPagePopupBottomProps = {
7907
7925
  onNext?: () => void;
7908
7926
  onResult?: () => void;
7909
7927
  stepIndex?: number;
7928
+ /** 결과 창이 떠 있는가 — 뜨는 순간 오디오 미터를 끈다(사용자 지시 2026-09-22). */
7929
+ resultOpen?: boolean;
7910
7930
  stepCount?: number;
7911
7931
  pageIndex?: number;
7912
7932
  /** 쪽 수. 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
  }
@@ -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 {
@@ -20,6 +20,11 @@ export interface SegmentedControlProps {
20
20
  /** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). **기본 40.** **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
21
21
  * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다.
22
22
  *
23
+ * **다만 768px 이하에서는 숫자 size 가 한 급 줄어든다**(Button 의 반응형 사다리: 48→40 · 40→34 · **32→28**; 1024px 이하에서는 32→30).
24
+ * 고르개는 그 Button 을 그대로 쓰므로 함께 줄어든다. 그래서 폰에서 `size={32}` 는 **28px** 로 선다.
25
+ * 같은 줄에 `size="Keyboard"`(32px 고정, 줄어들지 않는다) 단추를 세우면 폰에서만 4px 어긋난다 —
26
+ * 나란히 세워야 하면 양쪽 다 숫자 size 를 쓰거나 양쪽 다 Keyboard 를 쓴다(말귀 '표 설정' 패널 실측 2026-09-22).
27
+ *
23
28
  * 기본이 48 이던 것을 40 으로 내렸다(사용자 지시 2026-09-21). 코어가 제 안에서 세우는 고르개는 이미 **전부 40**
24
29
  * 이었고(`Toolbar`·`FilterPanel`·`BarChart`·`PanelRow`…), 옆에 서는 `Dropdown`·`TextField` 도 40 이다 —
25
30
  * 기본값만 혼자 48 이라 안 주고 쓴 자리가 한 급 크게 섰다. */
@@ -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
  }
@@ -54,9 +54,12 @@ export type TableProps<T extends {
54
54
  draggingId?: string | number | null;
55
55
  gridTemplateColumns?: string;
56
56
  /**
57
- * 좁은 화면(767px 이하)에서 쓸 열 틀. 주면 그 폭에서는 이 틀을 쓰고 **끌어 둔 폭(저장값)은 무시**한다 —
58
- * 폭 조절 손잡이와 '칸 너비 초기화' 단추도 숨겨, 좁은 화면에서 끈 값이 넓은 화면의 저장값을 덮지 않게 한다.
59
- * 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다. 미지정이면 좁은 화면도 넓은 화면과 같다(기본).
57
+ * 좁은 화면(767px 이하)에서 쓸 열 틀. 주면 그 폭에서는 이 틀을 쓴다.
58
+ *
59
+ * **안 줘도 좁은 화면에서는 끌어 둔 폭(저장값)을 안 쓰고, 손잡이와 '칸 너비 초기화' 단추도 숨긴다** —
60
+ * 이 prop 과 무관하게 늘 그렇다. 그래서 안 주면 좁은 화면에서는 `gridTemplateColumns` 가 그대로 서고,
61
+ * 그 틀의 `minmax(…px, …fr)` 밑값이 지켜지며 넘치는 만큼은 표가 제 가로 스크롤로 보여 준다.
62
+ * 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다.
60
63
  *
61
64
  * 열 틀은 인라인 CSS 변수로 들어가므로 앱 CSS 로는 `!important` 없이 못 이긴다 — 그래서 prop 이다.
62
65
  * 숨길 칸은 `renderRow` 의 `className`·`fields[].className` 으로 앱이 가린다.