@ihabdevteam/core 0.326.0 → 0.328.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.
@@ -1678,7 +1678,7 @@ export declare function copyText(text: string): Promise<boolean>;
1678
1678
  *
1679
1679
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1680
1680
  */
1681
- export declare const CORE_VERSION = "0.326.0";
1681
+ export declare const CORE_VERSION = "0.328.0";
1682
1682
 
1683
1683
  /** @public */
1684
1684
  declare const coreDefault: Record<string, unknown>;
@@ -5071,6 +5071,19 @@ export declare interface PopupListProps<T extends {
5071
5071
  /** 좁은 화면(767px 이하)에서 쓸 칸 폭 — 창 안의 표도 같은 `Table` 이라 그대로 전한다.
5072
5072
  * 문턱을 달리 하려면 CSS 에서 `--table-grid-cols-override` 를 준다(창은 body 로 나가므로 창의 `className` 으로 좁힌다). */
5073
5073
  mobileGridTemplateColumns?: string;
5074
+ /**
5075
+ * **칸 폭 저장 열쇠** — 주면 `Table` 로 그대로 간다((306), 말귀 청 2026-10-14).
5076
+ *
5077
+ * 한때 **일부러 안 줬다**: *"창은 열 때마다 새로 짓는 것이라 폭이 따라다니면 엉뚱하다."*
5078
+ * 그런데 `Table` 은 **자동맞춤을 저장 열쇠에 묶어** 두었다(`autoSignature` 가
5079
+ * `storeKey !== null` 을 본다) — 그래서 이 창으로 선 표는 **자동맞춤이 하나도 안 걸린다.**
5080
+ * 긴 글자가 든 칸이 눌려도 스스로 안 펴진다.
5081
+ *
5082
+ * 묶음을 통째로 푸는 길도 있었지만 그러면 **열쇠 없는 표 전부**가 따라 움직인다. 그래서
5083
+ * 칸 하나로 연다 — **안 주면 지금과 똑같다.** 줄지 말지는 부르는 쪽이 창마다 고른다
5084
+ * (폭이 따라다니길 바라지 않는 창에는 안 주면 된다).
5085
+ */
5086
+ resizeStorageKey?: string;
5074
5087
  /** 검색 placeholder */
5075
5088
  searchPlaceholder?: string;
5076
5089
  /**
@@ -28,6 +28,19 @@ export interface PopupListProps<T extends {
28
28
  /** 좁은 화면(767px 이하)에서 쓸 칸 폭 — 창 안의 표도 같은 `Table` 이라 그대로 전한다.
29
29
  * 문턱을 달리 하려면 CSS 에서 `--table-grid-cols-override` 를 준다(창은 body 로 나가므로 창의 `className` 으로 좁힌다). */
30
30
  mobileGridTemplateColumns?: string;
31
+ /**
32
+ * **칸 폭 저장 열쇠** — 주면 `Table` 로 그대로 간다((306), 말귀 청 2026-10-14).
33
+ *
34
+ * 한때 **일부러 안 줬다**: *"창은 열 때마다 새로 짓는 것이라 폭이 따라다니면 엉뚱하다."*
35
+ * 그런데 `Table` 은 **자동맞춤을 저장 열쇠에 묶어** 두었다(`autoSignature` 가
36
+ * `storeKey !== null` 을 본다) — 그래서 이 창으로 선 표는 **자동맞춤이 하나도 안 걸린다.**
37
+ * 긴 글자가 든 칸이 눌려도 스스로 안 펴진다.
38
+ *
39
+ * 묶음을 통째로 푸는 길도 있었지만 그러면 **열쇠 없는 표 전부**가 따라 움직인다. 그래서
40
+ * 칸 하나로 연다 — **안 주면 지금과 똑같다.** 줄지 말지는 부르는 쪽이 창마다 고른다
41
+ * (폭이 따라다니길 바라지 않는 창에는 안 주면 된다).
42
+ */
43
+ resizeStorageKey?: string;
31
44
  /** 검색 placeholder */
32
45
  searchPlaceholder?: string;
33
46
  /**
@@ -10,7 +10,7 @@ import { compareCellBlankLast } from '../../utils/textSort';
10
10
  import { cx } from '../../utils/cx';
11
11
  /** @public */
12
12
  export default function ListPopup(props) {
13
- const { title, items, columns, gridTemplateColumns, mobileGridTemplateColumns, searchPlaceholder, searchFn, defaultSortKey, defaultSortAsc = true, selectedId, selectedIds, onSelect, onClose, pageSizeOptions = [10, 25, 50, 100], initialPageSize, emptyText, noMatchText, countLabel, topContent, className = '', } = props;
13
+ const { title, items, columns, resizeStorageKey, gridTemplateColumns, mobileGridTemplateColumns, searchPlaceholder, searchFn, defaultSortKey, defaultSortAsc = true, selectedId, selectedIds, onSelect, onClose, pageSizeOptions = [10, 25, 50, 100], initialPageSize, emptyText, noMatchText, countLabel, topContent, className = '', } = props;
14
14
  const { t } = useTranslation();
15
15
  const resolvedSearchPlaceholder = searchPlaceholder ?? t('common.search', '검색');
16
16
  const resolvedEmptyText = emptyText ?? t('common.emptyList', '항목이 없습니다.');
@@ -100,12 +100,17 @@ export default function ListPopup(props) {
100
100
  })), [columns, sortKey, sortAsc, handleSort]);
101
101
  const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
102
102
  return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: clampedPage, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, mobileGridTemplateColumns: mobileGridTemplateColumns,
103
- /* 폭 조절은 켜되 **저장 열쇠는 안 준다** — 창은 열 때마다 새로 짓는 것이라 폭이 따라다니면
104
- 엉뚱하다. 그래서 이 창의 표는 끌어도 localStorage 에 아무 자국이 없고, 끈 폭은 창이 닫히면
105
- 사라진다. 폰에서 '오늘의 듣기 현황 상세보기' 가 `48px 48px …` 로 눌렸는데 저장소가
106
- 비어 있어 한동안 원인을 못 찾았던 자리다(2026-09-22) — 손잡이 띠에 스친 손짓이 메모리에만
107
- 폭을 박은 것이었다. 0.186.0 부터 좁은 화면에서는 손잡이도 없고 끈 폭도 안 쓴다. */
108
- resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
103
+ /* 폭 조절은 켜고, 저장 열쇠는 **부르는 쪽이 고른다**((306), 2026-10-14).
104
+
105
+ 한때 여기서 **일부러 안 줬다** — *"창은 열 때마다 새로 짓는 것이라 폭이 따라다니면
106
+ 엉뚱하다."* 그 뜻은 지금도 맞다. 다만 `Table` 이 **자동맞춤까지 저장 열쇠에 묶어**
107
+ 두어(`autoSignature`), 안 주면 **자동맞춤도 같이 꺼진다** — 긴 글자가 든 칸이 눌려도
108
+ 스스로 안 펴진다. 그 둘은 다른 일인데 한 손잡이에 달려 있다.
109
+
110
+ 폰에서 '오늘의 듣기 현황 상세보기' 가 `48px 48px …` 로 눌렸던 자리는 저장소가 비어
111
+ 있어 한동안 원인을 못 찾았다(2026-09-22) — 손잡이 띠에 스친 손짓이 메모리에만 폭을
112
+ 박은 것이었다. 0.186.0 부터 좁은 화면에서는 손잡이도 없고 끈 폭도 안 쓴다. */
113
+ resizable: true, resizeStorageKey: resizeStorageKey, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
109
114
  const isSelected = selectedIds != null
110
115
  ? (it.id != null && selectedIds.has(it.id))
111
116
  : (selectedId != null && it.id === selectedId);
@@ -127,6 +127,12 @@
127
127
  align-items: baseline;
128
128
  gap: 10px;
129
129
  }
130
+ /* **다시 듣기** — 문제 글 옆((303), 2026-10-14). 글이 길면 줄바꿈으로 밀리므로 안 줄인다.
131
+ `align-self` 로 올려 붙인다 — 줄이 `baseline` 이라 그대로 두면 단추가 글자 밑줄에 걸린다. */
132
+ .trp-row__replay {
133
+ flex-shrink: 0;
134
+ align-self: center;
135
+ }
130
136
  .trp-row__no {
131
137
  flex-shrink: 0;
132
138
  width: 32px;
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useId, useMemo } from "react";
3
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Button from '../Button';
6
6
  import Badge from '../Badge';
7
+ import { playOneShot } from '../Training/oneShotSound';
7
8
  import Popup from './Popup';
8
9
  import "./TrainingResultPopup.css";
9
10
  function pickFirstString(...vals) {
@@ -35,6 +36,21 @@ function pickFirstString(...vals) {
35
36
  function resolveProblem(it) {
36
37
  return pickFirstString(it?.question, it?.q, it?.sentence, it?.text, it?.prompt, it?.wording);
37
38
  }
39
+ /**
40
+ * **문제로 나왔던 그 소리** — 줄마다 '다시 듣기' 가 트는 것((303), 말귀 청 2026-10-14).
41
+ *
42
+ * 들려줄 것은 **정답 낱말이 아니라 들었어야 했던 것**이다. 위 `resolveProblem` 과 같은 결로
43
+ * 여러 이름을 더듬는다 — 부르는 쪽이 저마다 다른 이름으로 담아 넘겨 온 자리라
44
+ * (`question`·`q`·`sentence`…), 소리만 이름 하나를 못 박으면 그 쪽이 또 맞춰야 한다.
45
+ *
46
+ * **못 찾으면 `null` 이고, 그러면 단추를 안 그린다** — 안 넘기는 쪽은 지금과 똑같다.
47
+ */
48
+ function resolveSound(it) {
49
+ const v = pickFirstString(it?.questionSound, it?.soundSrc, it?.audioSrc, it?.sound, it?.audio, it?.src);
50
+ /* 글자이기만 하면 되는 게 아니다 — `pickFirstString` 은 아무 글이나 돌려준다.
51
+ 소리 자리에 제목이 들어 있는 꼴을 틀려고 하면 조용히 아무 일도 안 난다. */
52
+ return v && /^(https?:|blob:|data:audio|\/|\.\/)/.test(v) ? v : null;
53
+ }
38
54
  function resolveDecision(it) {
39
55
  if (!it)
40
56
  return undefined;
@@ -86,6 +102,22 @@ export default function TrainingResultPopup(props) {
86
102
  const total = contextsLength ?? 0;
87
103
  const correct = score ?? 0;
88
104
  const list = useMemo(() => Array.isArray(results) ? results : [], [results]);
105
+ /* **울리는 것은 하나다** — 다른 줄을 누르면 앞 소리가 멈춘다(`claimSound` 가 그 약속을 쥔다).
106
+ 창이 닫힐 때도 멈춘다 — `new Audio()` 로 만든 것은 DOM 에 없어 화면이 사라져도 혼자 운다. */
107
+ const playingRef = useRef(null);
108
+ const [playingRow, setPlayingRow] = useState(null);
109
+ const stopPlaying = useCallback(() => {
110
+ playingRef.current?.();
111
+ playingRef.current = null;
112
+ setPlayingRow(null);
113
+ }, []);
114
+ useEffect(() => stopPlaying, [stopPlaying]);
115
+ const replay = useCallback((i, url) => {
116
+ stopPlaying();
117
+ const s = playOneShot(url, () => { playingRef.current = null; setPlayingRow(null); });
118
+ playingRef.current = s.stop;
119
+ setPlayingRow(i);
120
+ }, [stopPlaying]);
89
121
  const rows = useMemo(() => list.map((it) => {
90
122
  const itr = it ?? {};
91
123
  // 문제 글이 비었을 때의 자리표시도 번역을 거친다 — 영어 화면에 한국어가 박혀 나왔다
@@ -95,7 +127,8 @@ export default function TrainingResultPopup(props) {
95
127
  const answer = corrects.length ? corrects.join(', ') : (itr.answer != null && itr.answer !== '' ? String(itr.answer) : null);
96
128
  const selectedText = typeof itr.selectedText === 'string' ? itr.selectedText : null;
97
129
  const isCorrect = resolveDecision(itr) === true;
98
- return { problem, variant, label, answer, selectedText, isCorrect };
130
+ const sound = resolveSound(itr);
131
+ return { problem, variant, label, answer, selectedText, isCorrect, sound };
99
132
  }), [list, getJudge, t]);
100
133
  const hasAnswerCols = useMemo(() => rows.some(r => r.answer !== null || r.selectedText !== null), [rows]);
101
134
  // 스크롤은 Popup의 .popup-content 하나만 담당한다 — 예전엔 결과 카드가 자체 스크롤
@@ -116,5 +149,11 @@ export default function TrainingResultPopup(props) {
116
149
  // 결과 계산 중에는 Esc 로도 완료(닫기)하지 않는다 — '완료' 단추와 같은 까닭
117
150
  beforeClose: blockWhilePending, footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [pending && _jsx("p", { id: reasonId, className: "trp-actions__reason caption-1", role: "status", children: t('training.result.pendingDone', '결과를 계산하는 중이라 아직 완료할 수 없습니다.') }), _jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation,
118
151
  /* 막되 초점은 남긴다(onDisabledClick 이 있으면 native disabled 대신 aria-disabled) — 키보드·낭독기가 까닭을 들을 수 있게 */
119
- disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? reasonId : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
152
+ disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? reasonId : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem }), r.sound && (_jsx(Button, { htmlType: "button", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: playingRow === i ? 'square' : 'volume-2', className: "trp-row__replay",
153
+ /* **줄 번호를 이름에 넣는다** — 줄마다 같은 단추가 서므로, 낭독기로
154
+ 단추 목록을 훑으면 `다시 듣기` 가 문항 수만큼 서서 못 가린다
155
+ (`TrainingCardGroup` 의 `접기` 와 같은 꼴). */
156
+ ariaLabel: playingRow === i
157
+ ? t('training.result.replaySoundStop', { no: i + 1, defaultValue: '{{no}}번 듣기 멈춤' })
158
+ : t('training.result.replaySound', { no: i + 1, defaultValue: '{{no}}번 다시 듣기' }), onClick: () => (playingRow === i ? stopPlaying() : replay(i, r.sound)) }))] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
120
159
  }
@@ -161,6 +161,12 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
161
161
  ${fileName ? `<title>${escapeHtml(fileName)}</title>` : ''}
162
162
  <style>${styles}</style>
163
163
  <style>
164
+ /* **화면에서 감춘 속 단추를 종이에서는 되살린다**((304), 2026-10-14).
165
+ 화면이 작아 감춘 표(word-table-cell--tap-too-small)가 복제본에 그대로 따라오는데, 종이는 A4 라
166
+ 작지 않다. 이 한 줄이 없으면 **작은 화면에서 뽑은 종이에만 재생 단추가 없다** — 그
167
+ 차이는 종이를 받아 든 사람에게만 보인다. */
168
+ .word-table-cell--tap-too-small .word-table-cell__play-button,
169
+ .word-table-cell--tap-too-small .word-table-cell__info-button { display: revert; }
164
170
  * { box-sizing: border-box; }
165
171
  html, body { margin: 0; padding: 24px 0; width: 100%; min-height: 100%; background: #f0f0f0; overflow: auto; }
166
172
  body { display: flex; flex-direction: column; align-items: center; gap: 24px; }
@@ -816,8 +816,17 @@
816
816
  (`○ 2 · △ 1 · ✕ 1`)은 배율을 안 타 또렷하므로, 폰에서 잃는 것은 *어느 칸이 △ 였나* 를
817
817
  모양으로 짚는 것 하나다.
818
818
 
819
- **안 고치는 것이 아니라 미루는 것이다.** 폰이 주 자리인 화면이 생기면 다시 재지 말고
820
- (라) 를 그대로 쓰면 된다. 말귀 쪽 `docs/DECISIONS_PENDING.md` 에 같은 기록이 있다.
819
+ **미룸을 거뒀다 — (라) 를 건다**(사장님 지시 (302), 2026-10-14). 2026-10-01 에는 *"매니저는
820
+ 핸드폰으로 볼 일이 매우 적다"* 로 미뤘는데, UI·UX 를 한 줄씩 정하시면서 이 자리를 다시
821
+ 고르셨다. **다시 재지 않는다** — 위 표와 넷 가운데 고르는 셈이 그대로 답이다.
822
+
823
+ 거는 자리는 `--on` 만이 아니라 **이 칸의 그림 전부**다(말귀 청). 고른 것만 굵히면 얹었을 때
824
+ 함께 서는 셋 중 하나만 두꺼워 **어느 것이 매긴 것인지를 굵기가 말해 버린다** — 그 일은 색과
825
+ `--on` 이 한다. */
826
+ .word-table-cell__score-slot svg {
827
+ stroke-width: 3.5;
828
+ }
829
+ /*
821
830
  ───────────────────────────────────────────────────────────────────────────── */
822
831
 
823
832
  /* **읽기만 하는 채점 표에서는 안 매긴 것을 안 드러낸다**(말귀 요청 ㊷, 2026-10-01).
@@ -837,6 +846,16 @@
837
846
  cursor: default;
838
847
  }
839
848
 
849
+ /* **겨눌 수 없을 만큼 작아지면 속 단추를 감춘다**((304), 2026-10-14).
850
+ 까닭과 문턱(WCAG 2.5.8, 24px)은 `WordTable.tsx` 의 `MIN_TAP_PX` 주석에 있다.
851
+ **속성이 아니라 클래스로 단다** — 속성으로 시작하는 고르개는 스코프 프리픽스가
852
+ `조상 꼴` 로 읽어 `scopePrefixNoDeadRules` 가 운다(처음 그렇게 짰다).
853
+ **지우지 않고 감춘다** — 인쇄 문서가 이 마디를 복제해 종이의 재생 단추를 짓는다. */
854
+ .word-table-cell--tap-too-small .word-table-cell__play-button,
855
+ .word-table-cell--tap-too-small .word-table-cell__info-button {
856
+ display: none;
857
+ }
858
+
840
859
  /* 단추 모양은 `Button` 컴포넌트가 낸다 — 여기서 다시 그리지 않는다.
841
860
  한때 이 자리에 직접 만든 원형 단추 CSS 가 있었는데, 그러면 앱의 단추와 크기·
842
861
  포커스 링·hover 규칙이 따로 놀았다. */
@@ -25,6 +25,8 @@ import { WORD_TABLE_PAPER, gridFor, CUT_CLASS, posLabels, wordTypeLabels, expand
25
25
  import { useFitScale } from './useFitScale';
26
26
  import { claimSound } from './Training/currentSound';
27
27
  import { playOneShot } from './Training/oneShotSound';
28
+ /** 누르는 자리의 바닥 — WCAG 2.5.8 Target Size (Minimum), AA. 손잡이 쓸이와 같은 자다. */
29
+ const MIN_TAP_PX = 24;
28
30
  // 외부(배럴·TodayPage·WordTableBlock 등) import 호환을 위해 재export
29
31
  /* 순수 함수(printWordTable·chunkItems)는 여기서 재수출하지 않는다 — 컴포넌트 파일이
30
32
  컴포넌트 아닌 것을 내보내면 Fast Refresh 가 깨져, 이 파일을 고칠 때마다 화면 상태가
@@ -155,7 +157,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
155
157
  obs.observe(el);
156
158
  return () => obs.disconnect();
157
159
  }, [refW, refH]);
158
- return (_jsxs("div", { ref: outerRef, className: cx('word-table-cell', hasImage ? 'word-table-cell--with-image' : ''), "data-ref-w": refW, "data-ref-h": refH, 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', '재생'), ariaLabel: label ? t('common.playFor', { name: label, defaultValue: `${label} 재생` }) : 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', '멈춤'), ariaLabel: label ? t('common.pauseFor', { name: label, defaultValue: `${label} 멈춤` }) : t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: (e) => { if (!isTouchPointer(e))
160
+ return (_jsxs("div", { ref: outerRef, className: cx('word-table-cell', buttonSize * scale < MIN_TAP_PX && 'word-table-cell--tap-too-small', hasImage ? 'word-table-cell--with-image' : ''), "data-ref-w": refW, "data-ref-h": refH, 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', '재생'), ariaLabel: label ? t('common.playFor', { name: label, defaultValue: `${label} 재생` }) : 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', '멈춤'), ariaLabel: label ? t('common.pauseFor', { name: label, defaultValue: `${label} 멈춤` }) : t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: (e) => { if (!isTouchPointer(e))
159
161
  handleInfoEnter(); }, onPointerLeave: (e) => { if (!isTouchPointer(e))
160
162
  handleInfoLeave(); },
161
163
  // 이 div 자체는 포커스 대상이 아니지만 안의 Button이 포커스를 받으면
@@ -30,8 +30,8 @@ export function useBarChartControl(data) {
30
30
  return `${m} ${weekLabel(t, o.week)}`;
31
31
  }, [t]);
32
32
  const monthOptions = useMemo(() => DATA_MONTH_OPTIONS.map(o => ({
33
- value: o.value, label: t('calendar.yearMonth', { year: o.year, month: o.month }),
34
- })), [t, DATA_MONTH_OPTIONS]);
33
+ value: o.value, label: yearMonthLabel(t, i18n.language, o.year, o.month),
34
+ })), [t, i18n.language, DATA_MONTH_OPTIONS]);
35
35
  const weekOptions = useMemo(() => WEEK_OPTIONS.map(o => ({ value: o.value, label: weekOptionLabel(o) })), [WEEK_OPTIONS, weekOptionLabel]);
36
36
  // 진입 시 기본 선택을 오늘 기준으로 — 오늘이 속한 월/주(데이터에 있으면), 없으면 최신
37
37
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -449,6 +449,8 @@
449
449
  "notHeard": "Didn't hear",
450
450
  "notJudged": "Not judged",
451
451
  "replay": "Replay",
452
+ "replaySound": "Replay item {{no}}",
453
+ "replaySoundStop": "Stop item {{no}}",
452
454
  "done": "Done",
453
455
  "viewResult": "View Results",
454
456
  "noContent": "(No content)"
@@ -441,6 +441,8 @@
441
441
  "notHeard": "못 들었어요",
442
442
  "notJudged": "미판정",
443
443
  "replay": "다시하기",
444
+ "replaySound": "{{no}}번 다시 듣기",
445
+ "replaySoundStop": "{{no}}번 듣기 멈춤",
444
446
  "done": "완료",
445
447
  "viewResult": "결과 확인",
446
448
  "noContent": "(내용없음)"
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.326.0";
49
+ export declare const CORE_VERSION = "0.328.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.326.0';
49
+ export const CORE_VERSION = '0.328.0';