@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
@@ -1,15 +1,17 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Button from '../Button';
6
6
  import AudioMeter from '../AudioMeter';
7
7
  import NoiseSettingsTooltip from './NoiseSettingsTooltip';
8
+ import Tooltip from '../Tooltip/Tooltip';
9
+ import useMediaQuery from '../../hooks/useMediaQuery';
8
10
  import { useNoisePlayer } from './NoisePlayerContext';
9
11
  import { useFloatingFocus } from './useFloatingFocus';
10
12
  import "./TrainingPagePopupBottom.css";
11
13
  /** @public */
12
- export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, onSettings, onNext, onResult, stepIndex = 0, stepCount = 0, pageIndex = 0, pageCount = 1, }) {
14
+ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, onSettings, onNext, onResult, stepIndex = 0, stepCount = 0, pageIndex = 0, pageCount = 1, resultOpen = false, }) {
13
15
  const { t } = useTranslation();
14
16
  const [open, setOpen] = useState(false);
15
17
  const [anchorRect, setAnchorRect] = useState(null);
@@ -58,7 +60,31 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
58
60
  onSettings?.();
59
61
  setOpen(true);
60
62
  }, [open, onSettings]);
61
- const handleToggleMeters = useCallback(() => setShowMeters(prev => !prev), []);
63
+ /* **폰에서는 미터가 바닥 줄에 서지 않는다.** 미터 두 줄이 222px 를 먹어 '다음 문제'(150px)가
64
+ 화면 밖으로 밀렸다 — 375px 에서 보이는 폭이 5px 뿐이었고, 바닥이 `overflow: visible` 이라
65
+ 굴려서 꺼낼 수도 없었다(말귀 실측 2026-09-22: 훈련을 이어갈 수 없다). 폰에서는 토글 단추
66
+ **위에 떠서** 보인다(사용자 지시). 데스크톱은 그대로 바닥 줄에 나란히 선다 — 거기서는
67
+ 자리가 넉넉하다(1659px 창에서 왼쪽 묶음 434px). */
68
+ const 폰 = useMediaQuery('(max-width: 767px)');
69
+ const 미터토글ref = useRef(null);
70
+ const [미터자리, set미터자리] = useState(null);
71
+ const handleToggleMeters = useCallback(() => {
72
+ setShowMeters(prev => {
73
+ const 다음 = !prev;
74
+ set미터자리(다음 ? (미터토글ref.current?.getBoundingClientRect() ?? null) : null);
75
+ return 다음;
76
+ });
77
+ }, []);
78
+ const 미터닫기 = useCallback(() => { setShowMeters(false); set미터자리(null); }, []);
79
+ /* **결과 창이 뜨면 미터를 끈다**(사용자 지시 2026-09-22). 결과를 보는 동안 미터가 떠 있을
80
+ 까닭이 없고, 창을 닫고 다음 판을 시작할 때도 깨끗한 자리에서 시작하는 편이 낫다.
81
+ 아래 설정 창 동기화와 같은 결이다 — 의도한 동기화라 set-state-in-effect 를 연다. */
82
+ /* eslint-disable-next-line react-hooks/set-state-in-effect */
83
+ useEffect(() => { if (resultOpen) {
84
+ setShowMeters(false);
85
+ set미터자리(null);
86
+ } }, [resultOpen]);
87
+ const 미터들 = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }));
62
88
  /* 설정 창은 판 밖(body)으로 포털된다 — 열리면 초점을 창 첫 칸으로, 닫히면 연 단추로(useFloatingFocus 참고) */
63
89
  const 창열림 = useNoiseSettingBtn && open;
64
90
  useFloatingFocus(창열림, () => (typeof document !== 'undefined' ? document.querySelector('.noise-settings__header')?.closest('.tooltip-content') ?? null : null), () => {
@@ -69,5 +95,5 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
69
95
  });
70
96
  return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick,
71
97
  /* 여닫이 — aria-pressed(selected) 대신 aria-expanded. 보이는 선택 모양은 같은 클래스로 */
72
- className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정'), minWidth: 52 }) }) })] })) : null, useNoiseSettingBtn && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') })), useNoiseSettingBtn && showMeters && (_jsxs("div", { className: "pp-bottom__meters", children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }))] }), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
98
+ className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", shape: "pill", icon: 'settings', onClick: handleMobileClick, className: open ? 'is-selected' : undefined, "aria-expanded": open, "aria-haspopup": "dialog", ariaLabel: t('training.settings', '설정') }) }) })] })) : null, useNoiseSettingBtn && (_jsx("div", { ref: 미터토글ref, className: "pp-bottom__meter-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, "aria-expanded": 폰 ? showMeters : undefined, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') }) })), useNoiseSettingBtn && showMeters && !폰 && (_jsx("div", { className: "pp-bottom__meters", children: 미터들 }))] }), useNoiseSettingBtn && showMeters && 폰 && 미터자리 && (_jsx(Tooltip, { anchorRect: 미터자리, onClose: 미터닫기, ariaLabel: t('training.meter.toggle', '오디오 미터 표시'), children: _jsx("div", { className: "pp-bottom__meters pp-bottom__meters--floating", children: 미터들 }) })), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
73
99
  }
@@ -326,3 +326,15 @@
326
326
  .audio-player__segments { display: flex; align-items: center; height: 100%; width: 100%; position: relative; gap: var(--bar-gap, 3px); }
327
327
  .audio-player__segment { position: relative; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: auto; cursor: pointer; padding: 10px 0; }
328
328
  .audio-player__segment-inner { display: flex; align-items: center; gap: var(--bar-gap, 3px); height: 100%; width: 100%; justify-content: center; }
329
+
330
+ /* **재생 중인 재생/멈춤 단추가 호버처럼 보였다.**
331
+ `aria-pressed="true"` 에 `transparent-white` 변형이 흰 배경을 칠한다(Button.css) — 파란 판
332
+ 위에서 그 하나만 흰 알약이 되어, 옆의 이전·정지·다시듣기·다음·완료(모두 투명)와 달라
333
+ 마우스를 얹은 것처럼 읽혔다(말귀 실측 2026-09-22: 재생 중 rgb(251,253,255) vs 나머지 투명).
334
+ **`aria-pressed` 는 그대로 둔다** — 상태를 오가는 단추라 낭독기에 필요하다. 여기서는 색만
335
+ 제자리로 돌린다. 상태는 아이콘(▶ ↔ ⏸)이 이미 말한다(채점 슬롯에서 쓴 것과 같은 결).
336
+ `:hover`·`:focus-visible` 은 건드리지 않는다 — 그건 진짜 손이 닿은 표시다. */
337
+ .audio-player__buttons .button--variant-transparent-white[aria-pressed='true']:not(:hover):not(:focus-visible) {
338
+ background: transparent;
339
+ color: #fbfdff;
340
+ }
@@ -25,6 +25,24 @@
25
25
  overflow: hidden;
26
26
  }
27
27
 
28
+ /* **좁은 화면에서는 "남는 높이에 맞춰 줄이기" 를 포기한다.**
29
+ 폰에서는 '표 설정' 패널이 세로로 쌓여 본문보다 커진다(말귀 375px 실측: 설정 566px ·
30
+ 본문 480px). 그 패널은 제 속 높이 아래로 안 줄어들므로 남는 높이가 **0** 이 되고,
31
+ `flex: 1; min-height: 0` 인 표 칸이 그 0 을 받는다 — 그 높이를 읽은 스케일러가 배율을
32
+ 0 으로 내 **표가 화면에서 사라졌다**. 칸이 없으니 채점을 할 수가 없다.
33
+ 여기서는 설정과 표를 그대로 세로로 잇고, 굴림은 이미 한 층 위 `.popup-content` 가 쥐고
34
+ 있으므로 그쪽에 맡긴다. 표에는 밑높이를 줘 어느 경우에도 보이게 한다. */
35
+ @media (max-width: 767px) {
36
+ .word-table-popup .popup-content__inner,
37
+ .word-table-popup__body {
38
+ min-height: auto;
39
+ }
40
+ .word-table-popup__table {
41
+ flex: 0 0 auto;
42
+ min-height: 320px;
43
+ }
44
+ }
45
+
28
46
  /* 페이지 분할 — 페이지별 단어표를 세로로 쌓고, 넘치면 스크롤(fitHeight 미적용, 각 페이지=한 용지) */
29
47
  .word-table-popup__pages {
30
48
  flex: 1;
@@ -262,8 +262,32 @@
262
262
  .phoneme-select-block__cells {
263
263
  flex-wrap: wrap;
264
264
  }
265
- .phoneme-select-block__cell-circle {
265
+ /* **줄을 바꾸게 두고, 줄어들지는 않게 한다.** 줄어드는 것은 감싸개(`__cell`)다 —
266
+ flex 항목은 기본으로 줄어들어 한 줄에 여럿이 서면 폭이 먼저 깎이고, 안의 알약은
267
+ 그 폭의 100% 라 함께 눌린다. 폰에서 칸이 33px 까지 눌려 높이(64)만 남은 **세로 알약**이
268
+ 됐고 그 안의 자모 셋이 10px 짜리 막대로 섰다(말귀 375px 실측 2026-09-22).
269
+ 위 `flex-wrap: wrap` 이 이미 있으므로, 안 줄어들면 대신 아랫줄로 내려간다. */
270
+ /* 묶음(대립쌍의 한 쪽)도 바깥 폭을 물려준다 — 이것이 없으면 그 안의 칸이 다시
271
+ 묶음의 속 내용으로 폭을 잡아 같은 순환에 빠진다(실측: 묶음 안 칸만 44px 로 남았다). */
272
+ .phoneme-select-block__cell-group {
273
+ width: min(240px, 100%);
274
+ flex-shrink: 0;
275
+ }
276
+ .phoneme-select-block__cell-group > .phoneme-select-block__cell {
277
+ flex: 1 1 auto;
278
+ min-width: 0;
279
+ width: auto;
280
+ }
281
+ .phoneme-select-block__cell {
282
+ /* **폭은 감싸개가 정한다.** 알약에만 `min(240px, 100%)` 을 주면 그 100% 가 감싸개 폭을
283
+ 되참조하는데, 감싸개 폭은 다시 알약의 **속 내용**으로 정해진다 — 빈 칸에서는 속이
284
+ 거의 없어 44px 로 쪼그라들었다(말귀 375px 실측: 33×64 세로 알약, 자모 셋이 10px 막대).
285
+ 감싸개가 바깥(`__cells`, 247px)을 기준으로 폭을 잡으면 순환이 끊긴다. */
266
286
  width: min(240px, 100%);
287
+ flex-shrink: 0;
288
+ }
289
+ .phoneme-select-block__cell-circle {
290
+ width: 100%;
267
291
  height: 64px;
268
292
  }
269
293
  .phoneme-select-block__cell-jamo-part {
@@ -245,6 +245,14 @@
245
245
  transform-origin: center center;
246
246
  }
247
247
 
248
+ /* **이미지가 있으면 채점 줄 자리를 비워 둔다.**
249
+ 채점 표시(`.word-table-cell__score`)는 칸 바닥에서 16px 위에 떠 있는데, 이미지 칸은 내용이
250
+ 그만큼 아래까지 내려와 발음 글자와 겹쳤다(데스크톱 실측 2026-09-22: 세로 23px · 가로 40px).
251
+ 글자만 있는 칸은 56px 떨어져 멀쩡하므로 그쪽은 건드리지 않는다. */
252
+ .word-table-cell--with-image .word-table-cell__inner {
253
+ padding-bottom: 48px;
254
+ }
255
+
248
256
  .word-table-cell__word {
249
257
  color: var(--color-black);
250
258
  text-align: center;
@@ -441,6 +449,17 @@
441
449
  display: inline-flex;
442
450
  }
443
451
 
452
+ /* **손가락으로 쓰는 기기에서는 셋을 늘 보인다.**
453
+ 위 규칙은 `:hover` 와 `:focus-within` 에 기대는데, 폰·태블릿에는 hover 가 없다 —
454
+ 안 매긴 칸의 단추 셋이 영영 0×0 이라 **채점을 시작할 수가 없었다**(말귀 실측 2026-09-22:
455
+ 여섯 칸 중 마우스가 얹힌 하나만 128×40, 나머지 0×0). 창 아래 안내는 "칸을 눌러 매기세요"
456
+ 라고 적혀 있지만 매기는 것은 슬롯 단추뿐이라, 보이지 않으면 길이 없다. */
457
+ @media (hover: none) {
458
+ .word-table-cell__score-slot {
459
+ display: inline-flex;
460
+ }
461
+ }
462
+
444
463
  /* 단추 모양은 `Button` 컴포넌트가 낸다 — 여기서 다시 그리지 않는다.
445
464
  한때 이 자리에 직접 만든 원형 단추 CSS 가 있었는데, 그러면 앱의 단추와 크기·
446
465
  포커스 링·hover 규칙이 따로 놀았다. */
@@ -137,6 +137,14 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
137
137
  // (Popup·CrosswordGame·Tooltip 과 같은 처리).
138
138
  if (typeof ResizeObserver === 'undefined')
139
139
  return;
140
+ /* **1 을 넘겨도 천장을 씌우지 않는다 — 일부러 둔 것이다.**
141
+ 기준값(refW·refH)은 안쪽 상자(280×380)보다 작아서, 이미지가 없는 칸에서는 배율이 1 을
142
+ 넘는다(실측 1.39583 — 가 · 가격 · 가격표 · 가계 모두 같다. 이미지가 있는 칸은 0.846).
143
+ 그러면 안쪽 상자가 칸 밖으로 나가 `overflow: hidden` 에 잘리지만, **잘리는 부분은 빈
144
+ 여백**이라 글자는 온전히 보인다(칸 335×339 · 상자 391×530). 천장을 씌우면 잘림은
145
+ 없어지지만 글자만 있는 칸의 글자가 이미지 칸 수준으로 작아진다 — 채점표는 치료사가
146
+ 고객에게 **보여 주며** 쓰는 물건이라 큰 글자가 이득이다(사용자 결정 2026-09-22).
147
+ 잘림이 눈에 띄는 자리가 생기면 그때 다시 본다. */
140
148
  const obs = new ResizeObserver(([entry]) => {
141
149
  const { width, height } = entry.contentRect;
142
150
  setScale(Math.min(width / refW, height / refH));
@@ -144,7 +152,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
144
152
  obs.observe(el);
145
153
  return () => obs.disconnect();
146
154
  }, [refW, refH]);
147
- return (_jsxs("div", { ref: outerRef, className: "word-table-cell", children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: (e) => { if (!isTouchPointer(e))
155
+ return (_jsxs("div", { ref: outerRef, className: cx('word-table-cell', hasImage ? 'word-table-cell--with-image' : ''), children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: (e) => { if (!isTouchPointer(e))
148
156
  handleInfoEnter(); }, onPointerLeave: (e) => { if (!isTouchPointer(e))
149
157
  handleInfoLeave(); },
150
158
  // 이 div 자체는 포커스 대상이 아니지만 안의 Button이 포커스를 받으면
@@ -187,7 +195,10 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
187
195
  /* 세 단추를 늘 함께 그린다. 매기지 않은 칸은 통째로 숨어 있다가 마우스를 올리면
188
196
  나타나고, 매긴 칸은 **고른 것만** 남는다(CSS 가 나머지를 감춘다). 그래야 표를
189
197
  훑어 "어디를 어떻게 봤는지"가 한눈에 들어온다. */
190
- _jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''), children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, showTooltipInfo: false, ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: (e) => { e.stopPropagation(); clickMark(cellIndex, m); } }) }, m))) }));
198
+ _jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''),
199
+ /* CSS 의 가운데 맞춤(translateX(-50%))을 그대로 두고 되돌림만 얹는다. 바닥 가운데를
200
+ 기준으로 키워야 칸 아래쪽에 붙은 자리가 흔들리지 않는다. */
201
+ style: 채점되돌림 > 1 ? { transform: `translateX(-50%) scale(${채점되돌림})`, transformOrigin: 'bottom center' } : undefined, children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, showTooltipInfo: false, ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: (e) => { e.stopPropagation(); clickMark(cellIndex, m); } }) }, m))) }));
191
202
  };
192
203
  const { t } = useTranslation();
193
204
  const isEdit = editCells !== undefined;
@@ -348,6 +359,14 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
348
359
  const contentClass = cx('word-table-block__content', CUT_CLASS[cutLine], showOutline && 'word-table-block__content--outline', showDivLine && hasPairs && 'word-table-block__content--div-line');
349
360
  // 빈칸(추가버튼)은 스케일 역보정으로 실제 크기 유지
350
361
  const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
362
+ /* **채점 단추는 표와 함께 줄어들면 안 된다.**
363
+ ○ · △ · ✕ 는 표 안에 있어 배율을 그대로 받는다 — 폰에서 배율이 0.28 이면 40px 단추가
364
+ 11px 이 되어 손가락으로는 못 누른다(말귀 실측·셈). 빈칸이 쓰는 것과 같은 되돌림을 씌운다.
365
+ 다만 끝까지 되돌리면(1/scale) 단추 셋(40×3 + 사이 8 = 128)이 **칸보다 넓어져** 옆 칸을
366
+ 침범한다 — 칸 폭을 천장으로 둔다. 이 견줌은 표 좌표에서 하므로 배율이 양쪽에서 약분된다.
367
+ 넓은 화면(배율 1)에서는 1 이라 아무것도 달라지지 않는다. */
368
+ const 채점행폭 = buttonSize * MARKS.length + 4 * (MARKS.length - 1);
369
+ const 채점되돌림 = Math.max(1, Math.min(1 / scale, (width / cols) / 채점행폭));
351
370
  const grid = (_jsxs("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: [_jsx("div", {
352
371
  /* title 로 Section 에 감쌀 때는 className 을 바깥 한 곳에만 — 두 곳에 붙어 소비자 스타일이 두 번 걸렸다 */
353
372
  className: cx('word-table', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: 내용ref, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
@@ -13,9 +13,25 @@ export function useFitScale(contentWidth, contentHeight) {
13
13
  const scalerRef = useRef(null);
14
14
  const [scale, setScale] = useState(1);
15
15
  const [offset, setOffset] = useState(0);
16
+ /* **잰 값이 0 이면 그 잣대는 안 쓴다.**
17
+ 높이까지 맞출 때는 부모를 재는데, 그 부모가 제 높이를 안 가진 자리(높이가 내용으로
18
+ 정해지는 통)에서는 0 이 나온다 — 그러면 배율이 0 이 되어 표가 화면에서 **아예 사라졌다**
19
+ (말귀 375px 채점 창 실측 2026-09-22: scale(0), 칸이 없으니 채점을 할 수가 없었다).
20
+ 0 을 "맞출 수 없다" 로 읽고 그 잣대만 빼면, 폭만으로 줄여 세로로는 굴려 보게 된다 —
21
+ 작아도 보이는 쪽이 없는 것보다 낫다. */
16
22
  const updateScale = useCallback((containerWidth, containerHeight) => {
23
+ /* **폭을 못 재면 아무것도 바꾸지 않는다** — 지난 값을 지킨다. 숨겨졌거나(디스플레이 none)
24
+ 아직 자리를 못 잡은 순간에 0 이 오는데, 그걸로 새로 정하면 배율이 0(사라짐)이나 1(넘침)로
25
+ 튀고 그 값이 다음 측정까지 남는다. */
26
+ if (!(containerWidth > 0))
27
+ return;
28
+ /* 높이는 **잴 수 있을 때만** 잣대로 쓴다. 높이까지 맞출 때는 부모를 재는데, 그 부모가 제
29
+ 높이를 안 가진 자리에서는 0 이 나온다 — 그대로 나누면 배율이 0 이 되어 표가 화면에서
30
+ 아예 사라졌다(말귀 375px 채점 창). 게다가 한 번 0 이 되면 축소된 내용의 높이도 0 이라
31
+ **부모도 계속 0** 이 되어 어느 크기로 돌려도 안 돌아왔다(창을 열어 둔 채 기기를 돌린
32
+ 경우 — 태블릿을 돌리면 표가 사라졌다). 0 을 "못 잼" 으로 읽어 그 고리를 끊는다. */
17
33
  const sw = containerWidth / contentWidth;
18
- const sh = contentHeight ? containerHeight / contentHeight : Infinity;
34
+ const sh = contentHeight && containerHeight > 0 ? containerHeight / contentHeight : Infinity;
19
35
  const s = Math.min(1, sw, sh);
20
36
  setScale(s);
21
37
  setOffset(Math.max(0, (containerWidth - contentWidth * s) / 2));
@@ -108,7 +108,8 @@
108
108
  "scheduled": "Scheduled",
109
109
  "noVisit": "No Show"
110
110
  },
111
- "periodSelect": "Period unit"
111
+ "periodSelect": "Period unit",
112
+ "visitCount": "{{count}} visit(s)"
112
113
  },
113
114
  "passCard": {
114
115
  "planSuffix": "Plan",
@@ -166,6 +167,7 @@
166
167
  "notifications": "Notifications",
167
168
  "account": "Account",
168
169
  "help": "Help",
170
+ "feedback": "Report a bug",
169
171
  "debug": "Debug",
170
172
  "coupons": "Coupons",
171
173
  "topic": "By Topic",
@@ -805,7 +807,7 @@
805
807
  "pickNoMatch": "No matching customer.",
806
808
  "pickAgain": "Choose another",
807
809
  "scoringFor": "Scoring {{name}}",
808
- "none": "Tap a cell to mark ○ · △ · ✕.",
810
+ "none": "Tap ○ · △ · ✕ under a cell to mark it. With a mouse, hover the cell to reveal them.",
809
811
  "tally": "{{marked}} scored · ○ {{o}} · △ {{triangle}} · ✕ {{x}} · {{rate}}%",
810
812
  "save": "Save score",
811
813
  "saved": "Saved {{count}} marks for {{name}}.",
@@ -106,7 +106,8 @@
106
106
  "scheduled": "예약",
107
107
  "noVisit": "미방문"
108
108
  },
109
- "periodSelect": "기간 단위"
109
+ "periodSelect": "기간 단위",
110
+ "visitCount": "방문 {{count}}건"
110
111
  },
111
112
  "passCard": {
112
113
  "planSuffix": "이용권",
@@ -164,6 +165,7 @@
164
165
  "notifications": "알림",
165
166
  "account": "계정",
166
167
  "help": "도움말",
168
+ "feedback": "버그신고",
167
169
  "debug": "디버그",
168
170
  "coupons": "쿠폰",
169
171
  "topic": "주제별 듣기",
@@ -803,7 +805,7 @@
803
805
  "pickNoMatch": "찾는 고객이 없습니다.",
804
806
  "pickAgain": "고객 다시 고르기",
805
807
  "scoringFor": "{{name}} 님 채점 중",
806
- "none": "칸을 눌러 ○ · △ · ✕ 를 매기세요.",
808
+ "none": "칸 아래 ○ · △ · ✕ 를 눌러 매기세요. 마우스로는 칸에 손을 얹으면 나타납니다.",
807
809
  "tally": "{{marked}}칸 채점 · ○ {{o}} · △ {{triangle}} · ✕ {{x}} · 정답률 {{rate}}%",
808
810
  "save": "채점 저장",
809
811
  "saved": "{{name}} 님의 채점 {{count}}칸을 저장했습니다.",
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.183.2";
49
+ export declare const CORE_VERSION = "0.185.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.183.2';
49
+ export const CORE_VERSION = '0.185.0';