@ihabdevteam/core 0.80.0 → 0.81.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,3262 @@
1
+ # CHANGELOG
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ > 아래 0.7.0 이전 기록에 나오는 `@ihab_giihan/core` · `.ihab-core-scope` ·
6
+ > `ihab-core.*` 는 **그때의 이름 그대로** 남겨 둔다. 지난 릴리스가 무엇이었는지를
7
+ > 적은 글이라, 지금 이름으로 고쳐 놓으면 그 시절에 그 이름으로 받은 사람이
8
+ > 제 이야기를 못 찾는다.
9
+
10
+ ## 0.81.0
11
+
12
+ ### 고침 — 배포 꾸러미에 `CHANGELOG.md` 가 안 실렸다
13
+
14
+ `files` 가 `dist` 뿐이라 소비자 `node_modules` 에는 `README.md`·`dist`·`package.json`
15
+ 만 있었다. 그런데 절차서는 "값만 바뀐 열쇠는 `node_modules` 의 CHANGELOG 맨 위
16
+ 절에서 보라" 고 적고 있었다 — **적혀는 있는데 따라 할 수 없는 글**로, 같은 날
17
+ 아침 `CONSUMING.md` 에서 잡은 것과 같은 꼴이다. 말귀가 잡았다.
18
+
19
+ `files` 에 `CHANGELOG.md` 를 넣는다. `test/changelogShips.test.ts` 가 `files` 를
20
+ 보고 맨 위 절의 판이 `package.json` 과 같은지도 본다(낡은 CHANGELOG 를 싣는 건
21
+ 안 싣는 것과 같다). `publish-check.sh` 는 tarball 에서 직접 본다.
22
+
23
+ 심어서 확인했다: `files` 에서 빼면 빨개지고, 판만 올리고 CHANGELOG 를 안 쓰면
24
+ 빨개진다.
25
+
26
+ ### 걷음 — `today.banner1`·`banner2`·`banner3` 여섯
27
+
28
+ 부르는 이가 코어·놀이터·소비자 셋 어디에도 없다(말귀 얼린 목록에도 여섯 다
29
+ 죽은 것으로 있었다). 코어의 `today.*` 히트는 전부 `Date` 메서드였다.
30
+
31
+ ## 0.80.0
32
+
33
+ ### 더함 — `trainingStatus.insight` 여덟: "무엇을 잘하고 무엇이 안 되나"
34
+
35
+ 훈련 대시보드 하단 구획의 낱말. 위의 차트들은 "얼마나 했나" 를 묻고 이 구획은
36
+ "잘하고 있나" 를 묻는다 — 그래서 `accuracy` 아래가 아니라 제 이름을 받는다.
37
+ `나아지는 것`·`요즘 안 하는 것` 은 정답률이 아니다.
38
+
39
+ ```
40
+ title 한눈에 보기 At a glance
41
+ strong 잘하는 것 Doing well
42
+ weak 안 되는 것 Struggling
43
+ improving 나아지는 것 Improving
44
+ declining 나빠지는 것 Slipping
45
+ stale 요즘 안 하는 것 Not lately
46
+ vsBefore 지난 기간 대비 {{delta}} vs. previous {{delta}}
47
+ thin 아직 견줄 만큼 하지 않았습니다 ({{n}}회) Not enough sessions to compare yet ({{n}})
48
+ ```
49
+
50
+ `{{delta}}` 는 부호와 `%p` 까지 부르는 쪽이 붙여 넘긴다(`+6%p`) — 사전이 `%p`
51
+ 를 알 필요가 없고 영어도 같은 꼴이라 한 열쇠로 된다. 값이 없으면 그 줄을
52
+ 안 그린다(`0%p` 는 안 한 것을 제자리걸음으로 바꾼다). `thin` 은 판이 셋 미만일
53
+ 때 카드 대신 서는 한 줄이다 — 빈 카드나 0% 는 '못한다' 로 읽힌다.
54
+
55
+ ## 0.79.0
56
+
57
+ ### 바꿈 — 고객 상세의 탭 이름 둘 (사용자가 화면을 보며 정한 문구)
58
+
59
+ ```
60
+ customers.detail.tabs.stats 통계 → 훈련 대시보드 / Statistics → Training dashboard
61
+ customers.detail.tabs.record 훈련기록 → 훈련 상세기록 / Training → Training details
62
+ ```
63
+
64
+ 열쇠는 그대로다 — 뜻(그 탭)은 안 바뀌고 **읽히는 말만** 바뀌므로 값을 고친다.
65
+ 이름을 새로 파면 옛 둘이 죽고 부르는 곳도 고쳐야 한다. 부르는 곳은 말귀
66
+ `CustomerDetailPopup` 두 줄뿐이고 다른 소비자에는 안 닿는다.
67
+
68
+ ## 0.78.0
69
+
70
+ ### 고침 — `./i18n` 갈래에서 판 번호를 물을 수 없었다
71
+
72
+ `exports` 의 갈래는 **저마다 따로 미리묶인다.** 그런데 `CORE_VERSION` 은 넷
73
+ (`.`·`/components`·`/hooks`·`/utils`)에서만 나가고 **사전이 오는 `/i18n` 에는
74
+ 없었다.** 그래서 소비자가 `components` 에 물어 새 판을 받아도 그 초록은 사전에
75
+ 대해 아무 말도 안 한 것이었다 — 말귀에서 `colRound` 가 디스크 사전에는 있는데
76
+ 미리묶인 사본에는 0건이었고, 그동안 `components` 는 내내 초록이었다.
77
+ **"판을 물어봤다" 가 안심시키는 오답이 된 꼴**로, 0.68.0 에서 잡은 것과 같은 결이다.
78
+
79
+ `src/i18n/index.ts` 도 `CORE_VERSION` 을 내보낸다.
80
+
81
+ ### 더함 — 갈래마다 판 번호가 있는지 세는 그물
82
+
83
+ `src/version.ts` 의 주석은 "네 갈래 모두에서 내보낸다" 고 적고 있었는데 그때 갈래는
84
+ 이미 다섯이었다. **갈래를 늘리는 사람이 주석을 고칠 까닭이 없다.** 그래서 세는 쪽을
85
+ 둔다 — `test/versionOnEverySubpath.test.ts` 가 `package.json` 의 `exports` 를 직접
86
+ 읽어 배럴 갈래마다 확인한다. `./types` 는 타입만 있어 봐주되, **값을 내보내기 시작하면
87
+ 그 봐주기도 깨진다.**
88
+
89
+ 심어서 확인했다: ① `/i18n` 의 한 줄을 도로 빼면 빨개진다 ② `types` 에 값을 하나
90
+ 더하면 봐주기가 깨진다 ③ 갈래를 새로 열고 적기를 잊으면 그 갈래 이름을 대고 운다.
91
+
92
+ ### 더함 — `trainingMiss` 에 묶는 단위 고르개 낱말 셋
93
+
94
+ `grainLabel`(보기 단위) · `grainRun`(문제별) · `grainItem`(문항별).
95
+ 같은 자료를 **어느 단위로 묶어 볼지** 고르는 말이다. `unit*` 은 이미
96
+ `unitRun`("판")·`unitItem`("개")이 세는 단위로 쓰고 있어 못 쓴다.
97
+
98
+ ### 걷음 — 부르는 이가 없어진 낱말 셋
99
+
100
+ `customers.trainingRecord.viewLabel` · `viewItems` · `viewRuns`.
101
+ 그 고르개는 화면에서 걷혔다. 코어·놀이터·소비자 셋 어디에서도 안 부른다.
102
+ **같은 묶음의 나머지(`colDone`·`unitRun`·`word`·`kindLabel` 등)는 살아 있다** —
103
+ 묶음째 걷으면 안 된다.
104
+
105
+ ## 0.77.0
106
+
107
+ ### 바꿈 — `wordScore.colGap` 을 걷고 `wordScore.colRound` 를 들인다
108
+
109
+ `회차` / `Round`.
110
+
111
+ 0.76.0 의 `colGap`(`시차`)은 **청이 잘못 온 것이었다.** 사용자가 곧바로 "시차가
112
+ 아니라 회차" 라고 고쳐 말했고, 말귀 쪽 세션이 바로 알려 왔다. 그 칸이 필요한 까닭도
113
+ 달랐다 — 지금 칸 **머리에 총 횟수**(`2회`)가 들어가 있어 머리와 줄 내용이 똑같이
114
+ `2회` 로 보이던 것이다.
115
+
116
+ `colGap` 은 **걷었다.** 소비자 셋에서 부르는 곳이 0곳임을 확인했다(낸 지 얼마
117
+ 안 됐고 아직 아무도 안 붙였다). 안 쓰는 낱말을 두면 다음 사람이 "이건 뭐지" 를
118
+ 묻게 되고, 쓰이지 않는 채로 틀려 있어도 아무도 모른다 — `common.days` 가 바로
119
+ 그렇게 `1 days` 인 채로 남아 있었다.
120
+
121
+ ### `common` 으로 안 올린 까닭
122
+
123
+ "`trainingMiss` 쪽에도 같은 칸이 **생길 수 있으니**" 라는 제안을 받았는데, 지금
124
+ 부르는 이는 하나다. 이 사전에서 칸 이름은 제 이름 아래 산다(`colWord`·`colMark`
125
+ ·`colTable` …). **생길 수 있다** 로 공용 자리를 만들면, 안 생겼을 때 그 자리가
126
+ 왜 거기 있는지 아무도 모른다. 실제로 둘째 부르는 이가 생기면 그때 올린다.
127
+
128
+ ## 0.76.0
129
+
130
+ ### 더함 — `wordScore.colJudge` · `wordScore.colGap`
131
+
132
+ `평가` / `Rating`, `시차` / `Gap`.
133
+
134
+ 단어 채점의 낱말별 창이 쓰는 칸 이름 둘이다. `맞힘` 은 이분법이라 ○△✕ 셋에 안 맞고,
135
+ 가장 가까운 `training.result.colJudge`(`판정`)는 **문장 훈련의 정답/오답 판정**이라
136
+ 뜻이 다르다. 칸 이름들 곁(`colMark`·`colLatest` 옆)에 뒀다.
137
+
138
+ ### 안 만든 것 — 간격 값은 `common.days` 가 이미 있다
139
+
140
+ `wordScore.gapDays` 를 청받았는데, **`common.days` 가 정확히 그 낱말이다**
141
+ (`{{count}}일` / `{{count}} days`). 새로 만들면 같은 말이 둘이 된다.
142
+ 말귀 쪽에서는 이 열쇠를 *죽은 열쇠* 로 얼려 두고 있었는데, 이제 부르는 이가 생긴다.
143
+
144
+ 달·해로 넘어갈 때의 표기는 **재 보고 정한다.** `90일` 이 치료사에게 안 읽히는지가
145
+ 먼저이고, 그 전에 `gapMonths` 를 만들면 아무도 안 청한 서식 규칙을 짓는 셈이다.
146
+
147
+ ### 고침 — `common.days` 의 영어가 `1 days` 였다
148
+
149
+ `{{count}} days` 하나뿐이라 1일도 `1 days` 로 나왔다. 이 사전은 이미 복수형
150
+ 접미사를 쓰는 자리가 있으므로(`customers.homework.done_one`·`_other`) 그 관행대로
151
+ `days_one`·`days_other` 로 갈랐다. 부르는 곳이 없어 지금까지 안 드러났다 —
152
+ 이번에 부르게 되면서 드러난 것이다.
153
+
154
+ ## 0.75.0
155
+
156
+ ### 고침 — **그물이 한글 이름을 못 보고 있었다**
157
+
158
+ `\w` 는 ASCII 다. 이 저장소는 이름을 한글로 짓는 자리가 많은데, 그물 둘이 이름을
159
+ `\w` 로 찾고 있었다. **같은 결함이 한글 이름이면 조용히 지나간다.**
160
+
161
+ 심어서 갈랐다 — `Slider.tsx` 에 이 줄을 넣었더니 **초록**이었다:
162
+
163
+ ```ts
164
+ const 심은정렬 = (l) => [...l].sort((가, 나) => (가.이름 > 나.이름 ? 1 : -1))
165
+ ```
166
+
167
+ 글자만 영문으로 바꾸니 곧바로 잡혔다(`0곳이었는데 1곳이다`). 결함은 그대로인데
168
+ **이름의 글자 종류만으로 눈이 멀었다.**
169
+
170
+ · `koreanSortOrder` 의 `[\w.]+` → 한글을 포함한 이름 글자로 넓혔다.
171
+ · `jsWritesCssReads` 의 `(?![\w-])` → 한글 경계도 막는다. 이게 없으면 `.가나` 가
172
+ `.가나다` 안에서 걸려 **안 읽는 클래스를 읽는다고 말한다**(실측).
173
+
174
+ **둘 다 "찾는 눈이 한글을 보는가" 를 따로 못박았다.** 눈이 머는 것은 오류가 아니라
175
+ "어긴 것 없음" 으로 통과하므로, 되돌림으로 확인하지 않으면 아무도 모른다.
176
+
177
+ 말귀 쪽 세션이 `\b` 로 같은 자리를 **세 번** 밟고 알려 줘서 제 그물도 재 봤다.
178
+ `\b` 는 `\w` 와 비-`\w` 사이의 자리라, 한글은 앞뒤가 모두 비-`\w` 여서 경계가
179
+ 아예 없다 — `/\b가나\b/.test(' 가나(a)')` 가 `false` 다.
180
+
181
+ ## 0.74.0
182
+
183
+ ### 고침 — **정렬 그물의 봐줄 목록이 파일 이름만 세고 있었다**
184
+
185
+ 0.73.0 의 `koreanSortOrder` 는 봐줄 자리를 **파일 이름**으로만 얼렸다. 그래서
186
+ **이미 목록에 있는 파일 안에 나쁜 줄을 하나 더 넣어도 조용히 지나갔다**(심어서
187
+ 확인했다: `useVisitSchedule.ts` 에 `a.name > b.name ? 1 : -1` 을 넣었는데 초록).
188
+
189
+ 정렬 결함은 *기존 표에 칸 하나 더 붙일 때* 들어오기 쉬운데, 그게 정확히 이 구멍으로
190
+ 샌다. **말귀 쪽 세션이 저희 그물에서 같은 구멍을 먼저 찾아 알려 줬다.**
191
+ 이제 자리 **수**까지 얼린다 — `useVisitSchedule.ts: 1곳이었는데 2곳이다`.
192
+
193
+ ### 넓힘 — 로케일 없는 `localeCompare` 도 함께 잰다
194
+
195
+ 0.73.0 에서 셋을 손으로 고쳤지만 **새로 생기는 것을 막을 것이 없었다.** 이제 함께
196
+ 센다. 남은 아홉 자리는 전부 기계 열쇠라 봐준다 — 목록에 **무엇을 견주는지** 적었다
197
+ (ISO 시각·코드). 그 구분이 적혀 있어야 다음 사람이 사람 글자와 가릴 수 있다.
198
+
199
+ **괄호 짝을 센다.** `localeCompare\([^)]*\)` 로 인자를 읽으면 첫 `)` 에서 끊겨
200
+ `(b.times[b.times.length - 1] ?? '').localeCompare(a.times[…] ?? '')` 같은 자리를
201
+ 잘못 읽는다 — 코어에 실제로 그 꼴이 있다. 말귀 쪽에서 그 함정도 함께 알려 줬다.
202
+
203
+ 셋을 심어 확인했다: 봐주는 파일 안에 비교자 하나 더, 같은 파일 안에 로케일 없는
204
+ `localeCompare` 하나 더, 그리고 괄호 짝 세기를 죽이기(거짓 양성 거르개).
205
+
206
+ ## 0.73.0
207
+
208
+ ### 고침 — **`ListPopup` 의 칸 정렬이 코드포인트 차례였다**
209
+
210
+ `sortAsc ? (va > vb ? 1 : -1) : (va < vb ? 1 : -1)` — `<`·`>` 는 코드포인트 차례다.
211
+ **한글끼리는 그 차례가 가나다와 맞아떨어져서 한국어 자료만 넣어 보면 끝까지 안
212
+ 드러난다.** 라틴 글자가 한글보다 앞이라 섞이는 순간 오름차순에서 `fwef` 가
213
+ `검수테스트2` 위에 선다. 말귀 쪽 세션이 저희 앱에서 같은 꼴을 겪고 알려 줘서
214
+ 코어를 훑다 찾았다 — 이 창은 말귀에서 **여섯 곳**이 쓴다.
215
+
216
+ **숫자 칸을 망가뜨리지 않게** 했다. 표의 칸에는 무엇이든 오는데 늘 글자로 견주면
217
+ `10` 이 `9` 앞에 선다. 둘 다 진짜 숫자일 때만 숫자로 견준다(`칸순`).
218
+
219
+ 곁들여 사람이 읽는 글자를 `localeCompare` 로 견주면서 **로케일을 안 박아 둔** 세
220
+ 자리도 맞췄다(`trainingMissStats` 의 title, `wordScoreStats` 의 label,
221
+ `wordTableRecommend` 의 name). 코어의 다른 자리들은 이미 `'ko'` 를 박고 있었다
222
+ (`LoadTablePopup`·`DeviceSideTable`·`WordSearchResultBlock`) — 그쪽에 맞춘 것이다.
223
+
224
+ `useVisitSchedule` 은 **그대로 둔다.** `2026-09-06 13:01` 처럼 0으로 채운 열쇠라
225
+ 코드포인트 차례가 곧 시간 차례다.
226
+
227
+ ### 그물 — `test/koreanSortOrder.test.ts`
228
+
229
+ **한글만 넣어 보면 안 드러난다**는 것 자체를 시험으로 못박았다 — 한국어 자료로는
230
+ 코드포인트와 사람 차례가 같아야 하고(전제), 섞이면 달라야 한다(진짜 자리).
231
+ 그리고 소스를 훑어 `a > b ? 1 : -1` 꼴이 새로 생기면 운다.
232
+
233
+ 셋을 심어 확인했다: `ListPopup` 을 옛 꼴로 되돌리기, `칸순` 이 숫자를 글자로
234
+ 견주게 만들기(고치면서 새 결함을 만드는 자리), 콜레이터를 코드포인트로 되돌리기.
235
+
236
+ **훑개가 한 번 헛짚었다** — `? 1 : 0` 까지 잡았더니 `SoundCompass` 의 SVG 호 방향
237
+ 플래그(`delta > 0 ? 1 : 0`)가 걸렸다. 비교 함수는 `-1` 을 내놓는다는 점으로 좁혔다.
238
+
239
+ ## 0.72.0
240
+
241
+ ### 더함 — `SummaryStrip` 의 `flat`
242
+
243
+ **이미 떠 있는 것 위에서는 또 뜨면 안 된다.** 기본은 `--shadow-card` 를 얹은
244
+ "떠 있는 판"인데, 창(`Popup`) 안에 놓으면 창이 이미 크게 떠 있어(`20px 20px 20px`)
245
+ 그 안의 셈 줄만 도드라진다. 바로 아래 `Table` 은 그림자가 없어 **둘만 갈려 보인다**
246
+ (말귀 실측 — 테두리·모서리·바탕은 셋이 똑같고 그림자만 달랐다).
247
+
248
+ **"창 안이면 저절로" 로 하지 않았다.** `.popup .summary-strip { box-shadow: none }`
249
+ 을 코어가 들면 이 컴포넌트만 예외가 된다 — `--shadow-card` 를 쓰는 코어 컴포넌트가
250
+ **열한 가지**인데 그중 하나만 창 안에서 다르게 굴면, 다음 사람이 `StatChart` 를
251
+ 창에 넣고 왜 이건 뜨는지 묻는다. 어디서 평평할지는 화면이 정하는 편이 맞다.
252
+
253
+ 그림자만 뺀다 — 테두리·모서리·바탕은 그대로다. 그래야 바로 아래 `Table` 과 같은
254
+ 결로 읽힌다(실측: `flat` 인 것만 `box-shadow: none`, 나머지 셋은 그대로).
255
+
256
+ 기존 화면은 하나도 안 바뀐다. 부르는 쪽이 `flat` 을 줄 때만 달라진다.
257
+
258
+ ### 그물 — `test/summaryStripFlat.test.tsx`
259
+
260
+ **표식이 붙는지(JS)와 그 표식이 그림자를 빼는지(CSS)를 나눠 잰다** — 둘 중 하나만
261
+ 맞으면 화면에서는 아무 일도 안 일어난다. 셋을 심어 확인했다: 표식만 붙이고 CSS 빼기,
262
+ CSS 만 두고 표식 안 붙이기, 그리고 `flat` 이 테두리·바탕까지 지우게 만들기
263
+ (마지막은 거짓 양성 거르개 — 통째로 지워도 앞의 시험들은 통과한다).
264
+
265
+ ## 0.71.0
266
+
267
+ ### 고침 — **내용으로 크기가 정해지는 트랙이 표를 조용히 어긋나게 했다**
268
+
269
+ `Table` 은 머리 한 줄(`.table-fields`)과 몸통 각 줄(`.table-row`)이 **서로 다른
270
+ 격자**인데 같은 track list 를 쓴다. `auto`·`min-content`·`max-content`·
271
+ `fit-content()` 는 **그 격자 안의 내용**으로 풀리므로 머리는 이름표 폭으로, 몸통은
272
+ 셀 내용 폭으로 각자 달라진다. 그러면 남은 자리를 나눠 갖는 `fr` 까지 함께 밀려
273
+ 표 전체가 어긋난다.
274
+
275
+ 놀이터의 `데이터 테이블` 시연이 실제로 그랬다 — `1fr 1.6fr 0.9fr 0.8fr 1fr auto`
276
+ 의 마지막 칸이 **머리 56px · 몸통 176px**, 앞 칸 경계가 **120px** 어긋나 있었다.
277
+ 눈으로는 "그 열만 좀 밀렸네" 로만 보이고 오류도 경고도 없어 오래 안 드러났다.
278
+
279
+ **소비자 셋에는 이 꼴이 0곳**이었다 — 놀이터만 그 잘못된 본을 가르치고 있었다.
280
+ 시연과 그 옆 Usage 를 `120px` 로 고쳤고, 앞으로 소비자가 같은 함정에 빠지지 않게
281
+ **개발 모드 경고**를 달았다(운영 빌드에서는 아무 일도 하지 않는다).
282
+
283
+ `minmax(auto, 1fr)` 처럼 **안에 든** 것도 잡는다 — 최솟값이 내용 기반이면 마찬가지다.
284
+ 처음에는 트랙의 맨 앞만 보다가 심어서 그 구멍을 찾았다.
285
+
286
+ ### 그물 — `scripts/table-align.browser.js`
287
+
288
+ 머리와 몸통의 **경계선 x 좌표**를 견준다(폭이 아니라 경계다 — 폭은 반올림으로
289
+ 1px 씩 흔들려도 경계가 맞으면 눈에는 맞아 보인다). **이 훑개를 세우자마자 위의
290
+ 120px 을 잡았다.**
291
+
292
+ 놀이터 **138쪽 전부** 훑어: 표가 있는 쪽 13 · 표 26개 · 줄 157개를 판정해
293
+ **어긋난 표 0**. 심어서 확인했다 — 칸에만 왼쪽 여백을 주면 어긋난 표가 1 → 3 이 된다.
294
+
295
+ `test/tableContentTrackWarn.test.tsx` 가 경고 쪽을 잰다. 셋을 심어 확인했다:
296
+ 경고를 통째로 빼기, 늘 경고하게 하기, 그리고 판별을 넓혀 보기(이것이 규칙의
297
+ 구멍을 드러냈다).
298
+
299
+ ## 0.70.0
300
+
301
+ ### 고침 — 표 칸 되돌리기가 **남는 자리를 비율대로 나눈다**
302
+
303
+ 0.69.0 은 잰 값을 그대로 써서, 칸이 제 글자만큼만 차지하고 표 오른쪽에 빈 자리가
304
+ 남았다 — **다 왼쪽에 붙어 보였다.** 이제 고정 px 열을 뺀 나머지를 **잰 크기의
305
+ 비율대로** 늘리거나 줄여 그릇을 꽉 채운다(좁으면 줄고 넓으면 는다 — 한 식으로
306
+ 둘 다 된다). 반올림으로 남는 몇 px 은 가장 넓은 유동 열에 얹는다.
307
+
308
+ 실측(놀이터, 그릇 752):
309
+
310
+ | | 칸 | 빈 자리 |
311
+ |---|---|---|
312
+ | 고치기 전 | 52 · 83 · 188 · 96 | **333** |
313
+ | 끌어 놓은 뒤 | 52 · 223 · 188 · 96 | 193 |
314
+ | 누른 뒤 | 52 · 157 · 359 · 184 | **0** |
315
+
316
+ `grow` 와 `fit` 이 이제 같은 결과로 모인다.
317
+
318
+ 아이콘은 `chevrons-left-right-ellipsis`(lucide `ChevronsLeftRightEllipsis`)다 —
319
+ 정적 아이콘 목록에 함께 등록했다. 등록을 빠뜨리면 예외도 경고도 없이 **빈 네모**가
320
+ 그려지는데, `iconNamesExist` 문이 그것을 잡아 준다(이번에도 한 번 잡혔다).
321
+
322
+ 놀이터의 `resizable` 시연 둘을 `pbox--full` 로 두었다. 내용 폭으로 줄어드는 칸에
323
+ 두면 **표 자체가 좁아져** "비율대로 채운다" 가 안 보인다(실측: 그릇이 561 → 419 로
324
+ 줄어 빈 자리가 0 으로 나오지만 표가 왼쪽에 몰린다).
325
+
326
+ ### 더함 — `customers.link.linkPending`
327
+
328
+ `연결 요청을 보냈습니다. 회원이 수락하면 연결됩니다.` /
329
+ `Request sent. The member will be linked once they accept.`
330
+
331
+ **화면이 성공을 실패라고 말하고 있었다.** 말귀 회원 연결 창에서 회원을 이으면
332
+ `연결에 실패했습니다.` 가 뜨는데, 라이브 DB 에는 `link_pending_user_id` 가 적히고
333
+ 그 회원에게 `기관 연결 요청` 알림도 1건 들어가 있었다(2026-09-09 실측).
334
+
335
+ 까닭은 서버 계약이다. `manager_link_member` 는 매니저 혼자 잇지 못하게 해 두어
336
+ 요청만 만든 뒤 `{ linked: false, pending: true }` 를 돌려주는데, 이 꼴에는
337
+ `reason` 이 없어 앱이 뭉뚱그린 실패 문구로 떨어뜨렸다. `customers.link` 에는
338
+ `linkSuccess`·`linkError` 뿐이라 **중간 상태를 말할 낱말이 없었다.** 성공·대기·
339
+ 실패가 나란히 서게 `linkSuccess` 바로 뒤에 두었다.
340
+
341
+ ### 사람이 정할 몫으로 남긴 것 — 연결 창이 이메일을 묻는데 서버는 전화로 찾는다
342
+
343
+ 같은 창의 입력칸이 `customers.link.emailLabel`(`이메일`)·`emailPlaceholder`
344
+ (`이메일 입력`)인데, `manager_link_member(p_customer_id, p_phone)` 는
345
+ `phone_digits` → sha256 으로 `profiles.phone_hash` 를 맞춘다. 말귀 쪽 실측:
346
+ **진짜 이메일을 넣으면 `일치하는 말귀 회원이 없습니다.`**, 전화번호를 넣어야
347
+ 요청이 나간다. 즉 칸에 적힌 대로 하면 절대 안 된다.
348
+
349
+ 낱말을 고칠지(`연락처`), 아니면 `phoneLabel`·`phonePlaceholder` 를 따로 내고 앱이
350
+ 갈아탈지는 **화면 뜻이 바뀌는 일이라 사람이 정한다.** 사전 상태에 엇갈린 흔적이
351
+ 남아 있다 — `customers.member.placeholder` 는 `이메일 또는 전화`,
352
+ `searchPlaceholder` 는 `이름·이메일·전화 검색` 인데 연결 창의 `customers.link.*`
353
+ 만 이메일 전용이다.
354
+
355
+ ## 0.69.0
356
+
357
+ ### 더함 — **표 칸 너비 되돌리기 단추**
358
+
359
+ `resizable` 인 표의 머리 오른쪽에 `Button size={40} type="dot"` 을 둔다.
360
+ 누르면 **내용에 맞춰 칸을 다시 잡는다** — 기본 격자로 돌아가는 것이 아니라,
361
+ 잠깐 `max-content` 로 두고 머리 한 줄과 몸통 모든 줄의 칸을 재어 열마다 가장
362
+ 넓은 값을 쓴다(잰 뒤 바로 되돌리므로 화면에는 안 보인다). 고정 px 로 못 박은
363
+ 열(아이콘·숫자 칸)은 그대로 둔다.
364
+
365
+ `grow` 는 px 라 내용이 그릇보다 넓으면 고정 열을 뺀 나머지를 같은 비율로 줄여
366
+ 담는다(최소 폭 48px 는 지킨다). `fit` 은 이 값이 fr 가중치가 되어 표 폭이 그대로다.
367
+ `resizeStorageKey` 를 주면 결과를 저장한다.
368
+
369
+ 브라우저 실측(놀이터 1280 폭):
370
+
371
+ | | 끌어 놓은 뒤 | 누른 뒤 | |
372
+ |---|---|---|---|
373
+ | `grow` | 52 · 152 · 32 · 32 | 52 · 79 · 179 · 97 | 넘침 0 |
374
+ | `fit` | 52 · 245 · 132 · 134 | 52 · 115 · 262 · 134 | 표폭 604 → 604 그대로 |
375
+
376
+ ### 만들면서 두 번 밟은 것 — **머리와 몸통의 정렬**
377
+
378
+ 이 표는 머리 한 줄(`.table-fields`)과 몸통 각 줄(`.table-row`)이 **서로 다른 격자**인데
379
+ 같은 `--table-grid-cols` 를 써서 겹쳐 보인다. 한쪽만 좁아지면 열이 어긋난다.
380
+
381
+ 1. 단추를 `.table-fields` **안에** 넣으면 그 자식이 곧 열이라 **칸이 하나 는다.**
382
+ → 겉을 한 겹 씌우고 그 안에서 자리를 안 차지하게 얹는다.
383
+ 2. 마지막 열에 안쪽 여백을 줘 단추 자리를 비우려 했더니, `1fr` 트랙의 자동 최소
384
+ 크기가 min-content 라 **그 열이 다른 열의 자리를 빼앗았다**(실측: 머리
385
+ 52/32/32/56 vs 몸통 52/40/40/40). `min-width: 0` 을 얹어도 안 풀렸다.
386
+ → **담은 상자 양쪽에 같은 여백**을 준다. 둘 다 같은 만큼 좁아져 정렬이 지켜진다.
387
+
388
+ ### 그물 — `test/tableResetColumns.test.tsx`
389
+
390
+ jsdom 에는 레이아웃이 없어 **재는 값이 아니라 이어짐**을 잰다: 눌렀을 때 폭이 실제로
391
+ 쓰이는지, 저장되는지, **열 개수가 그대로인지**, 그리고 머리·몸통의 여백이 **한 규칙에
392
+ 함께** 적혀 있는지(갈라지면 어긋난다). 셋을 심어 확인했다 — 단추를 격자 안으로
393
+ 옮기기, 몸통 쪽 여백만 빼기, 누를 때 아무 일도 안 하게 하기.
394
+
395
+ 곁들여 사전에 `table.resetColumns` 를 더했고, 놀이터 `Table` 쪽에 `fit` 모드 시연을
396
+ 함께 두었다(그 상자는 `pbox--full` 로 폭을 채운다 — 내용 폭으로 줄어드는 칸에서는
397
+ `fit` 의 성질이 안 보인다).
398
+
399
+ ## 0.68.0
400
+
401
+ ### 고침 — **"새 판을 보고 있나" 확인법이 콘솔에서 안 되는 방법이었다**
402
+
403
+ `src/version.ts` 와 `CONSUMING.md` 가 둘 다 이렇게 적고 있었다:
404
+
405
+ ```js
406
+ import('@ihabdevteam/core/components').then(m => console.log(m.CORE_VERSION))
407
+ ```
408
+
409
+ **콘솔에서는 저 맨 이름이 안 풀린다** — `Failed to resolve module specifier` 가 난다
410
+ (2026-09-09 실측). 맨 이름은 번들러가 푸는 것이라 콘솔에는 그 지도가 없다.
411
+ 그런데 그 글이 있는 절의 제목이 **"판 번호로 확인하지 마라 — 브라우저에서 봐라"** 다.
412
+ 즉 하라는 자리에서 안 되는 방법을 알려 주고 있었다. 오늘 이 자리에서 두 사람이
413
+ 헛걸음했다.
414
+
415
+ 고쳐 적었다:
416
+ · 앱 코드 안에서는 맨 이름 그대로 `import { CORE_VERSION } from '…'`
417
+ · **Vite dev 콘솔에서는 `/@id/` 를 앞에 붙인다** — `import('/@id/@ihabdevteam/core/components')`
418
+ (실측으로 0.67.0 을 돌려받았다)
419
+ · **CSS 가 궁금한 것이라면 CSSOM 을 직접 읽는 편이 낫다** — 번들러와 무관하고,
420
+ 판 번호가 아니라 **그리는 데 실제로 쓰이는 값**을 본다. 말귀 쪽 세션이 찾아낸
421
+ 방법이고, 0.67.0 의 `pointer-events` 를 그것으로 갈랐다.
422
+
423
+ ## 0.67.0
424
+
425
+ ### 고침 — **막힌 단추의 `showTooltipInfo` 가 한 번도 안 떴다**
426
+
427
+ 이 툴팁을 다는 까닭은 대개 **"왜 막혔는지" 를 말하려는 것**인데, 정확히 그 자리에서만
428
+ 안 떴다. 말귀에서 네 자리가 그러고 있었다 — 그중 하나는 고객 상세의 `훈련 시작` 으로,
429
+ 다른 고객 훈련이 진행 중이면 막히며 사유를 붙여 두었는데 아무도 그 글을 못 봤다.
430
+
431
+ **막는 자리가 셋이었다.** 하나만 걷어서는 안 뜬다:
432
+
433
+ 1. CSS `.button:disabled { pointer-events: none }` — 마우스가 단추에 안 닿는다
434
+ 2. `showTooltipFn` 의 `!disabledProp` 가드 — 이벤트가 와도 안 연다
435
+ 3. "열린 채로 disabled 가 되면 즉시 닫기" 효과 — 열려도 곧바로 닫힌다
436
+
437
+ 브라우저에서 포인터 이벤트를 **직접 쏘아 넣어도** 툴팁이 0개인 것으로 갈랐다
438
+ (CSS 만의 문제가 아니라는 뜻이다). 3번은 1번 때문에 있던 것이다 — 막히는 순간
439
+ 포인터가 꺼져 `pointerleave` 가 안 와 툴팁이 영영 붙어 있었다. 1번을 걷으면
440
+ leave 가 제대로 오므로 함께 걷었다.
441
+
442
+ ### ⚠️ 곁따라 바뀌는 것 — **막힌 단추가 뒤로 클릭을 안 흘린다**
443
+
444
+ `pointer-events` 를 되살리면 히트 대상이 바깥 상자에서 **단추 자신**으로 바뀐다.
445
+ 고정 시험대에 진짜 클릭을 넣어 쟀다:
446
+
447
+ | | 단추 | 바깥 상자 |
448
+ |---|---|---|
449
+ | 옛 꼴 (`pointer-events: none`) | 0 | **1** — 뒤로 샌다 |
450
+ | 새 꼴 | 0 | **0** — 삼킨다 |
451
+
452
+ **회색 단추를 눌렀는데 뒤의 카드가 열리던 것이 사라진다.** 옳은 쪽이라고 보지만
453
+ 동작이 바뀌는 것은 맞다 — 막힌 단추를 **누를 수 있는 상자 안에** 둔 자리가 있으면
454
+ 확인이 필요하다(예: `onClick` 을 가진 `TrainingCard` 안의 막힌 `topActions`).
455
+
456
+ 클릭이 새지는 않는다 — 네이티브 `<button disabled>` 는 클릭 이벤트를 아예 안
457
+ 내보낸다. 맨 단추로 기준선을 잡아 견줬고 둘 다 0이다.
458
+
459
+ hover 모양도 안 샌다 — hover 규칙 일곱은 모두 `.button--variant-<이름>:hover` 인데
460
+ 막히면 그 자리가 `--variant-disabled*` 로 바뀌어 하나도 안 걸린다.
461
+
462
+ ### 그물 — `test/disabledButtonTooltip.test.tsx`
463
+
464
+ 막던 셋을 각각 되돌려 빨개지는 것을 확인했고, `pointer-events: none` 을 **주석에만**
465
+ 넣는 거짓 양성도 심었다(글자 훑기였다면 여기서 헛통과한다). 안 막힌 단추가 그대로
466
+ 뜨는지, 툴팁을 안 준 막힌 단추는 아무것도 안 띄우는지, 막힌 단추의 `onClick` 이
467
+ 안 불리는지도 함께 잰다.
468
+
469
+ 놀이터 `Button` 쪽에 "막힌 단추에 사유를 붙일 때" 시연을 더했다 — `bar`·`dot` 둘 다.
470
+
471
+ ## 0.66.0
472
+
473
+ ### 더함 — `trainingMiss.correct` · `trainingMiss.wrong`
474
+
475
+ `정답`·`오답` / `Right`·`Wrong`.
476
+
477
+ 오답 문항의 회차별 풀이 창(`CustomerTrainingItemPopup`)이 마지막 칸에 그 회차를
478
+ 맞혔나 틀렸나를 배지로 세운다. 지금까지는 **`game.crossword.cellCorrect`·
479
+ `cellWrong` 을 빌려** 쓰고 있었다 — 값은 맞지만 십자말풀이 이름 아래 있는 것이라
480
+ 그쪽을 정리하다 지우면 여기가 깨진다.
481
+
482
+ **en 은 요청받은 `Correct` 대신 `Right` 로 냈다.** 그 배지가 서는 칸의 이름이
483
+ `trainingMiss.colScore`(ko `맞힘` / en **`Correct`**)다. 즉 영어로는 **칸 이름과
484
+ 그 칸의 값이 같은 낱말**이 된다 — 같은 표 안에서. 한국어는 `맞힘` vs `정답`/`오답`
485
+ 로 갈리니 이 문제가 없다. `Right`/`Wrong` 은 짝이 자연스럽고 칸 이름과 안 겹친다.
486
+
487
+ (지금 빌려 쓰는 십자말풀이 값은 en 이 소문자 `correct`/`wrong` 이라 대소문자로만
488
+ 갈려 있었다.)
489
+
490
+ ## 0.65.0
491
+
492
+ ### 바꿈 — 훈련 오답 표의 칸 이름 둘
493
+
494
+ 사용자가 화면을 보며 정한 문구다.
495
+
496
+ | 열쇠 | ko | en |
497
+ |---|---|---|
498
+ | `trainingMiss.colTitle` | `문항` → **`제목`** | `Item` → **`Title`** |
499
+ | `trainingMiss.colLatest` | `마지막` → **`일시`** | `Last` → **`Latest`** |
500
+
501
+ **en 은 요청받은 `When` 대신 `Latest` 로 냈다.** 같은 이름 아래
502
+ `trainingMiss.colWhen` 이 이미 en `When` 이고, 그 둘이 **한 화면에 위아래로 선
503
+ 두 표**의 칸 이름이다(`CustomerTrainingMissPanel` — 위 표는 판별 기록의 `훈련한 때`,
504
+ 아래 표는 문항별 `마지막`). 한국어는 `훈련한 때`·`일시` 로 갈리는데 영어만
505
+ `When`·`When` 으로 겹친다. `Latest` 면 "가장 최근" 이라는 뜻을 지키면서 겹치지 않는다.
506
+
507
+ `wordScore.colLatest`(ko `마지막` / en `When`)와 en 을 맞추자는 제안이었는데,
508
+ 그쪽에는 `colWhen` 이 없어 겹칠 일이 없다. 겹침을 피하는 쪽을 골랐다.
509
+
510
+ ## 0.64.0
511
+
512
+ ### 걷어냄 — `trainingStatus.homework.weeklyRate`
513
+
514
+ 0.62.0 에서 들인 `totalRate` 가 그 자리를 대신했다. 말귀가 옮겼고
515
+ (`TrainingStatusPage.tsx`), 놀이터 여섯 자리는 0.62.0 에서 함께 옮겼다.
516
+ 지우기 전에 코어·놀이터·소비자 넷을 다시 훑어 부르는 곳이 **0곳**임을
517
+ 확인했다(열쇠를 변수로 조립하는 자리도 함께 봤다).
518
+
519
+ **낱말이 거짓말을 하던 자리가 이것으로 닫혔다** — 링 차트의 숫자는 기간을
520
+ 안 받는데 라벨만 "주간" 이라고 적고 있었다.
521
+
522
+ ## 0.63.0
523
+
524
+ ### 더함 — `customers.member.unlinkConfirm`
525
+
526
+ `연결을 끊으면 이 고객이 다시 이으려면 회원이 직접 수락해야 합니다. 끊을까요?` /
527
+ `Unlinking means the member must accept a new request to reconnect. Unlink?`
528
+
529
+ 고객 상세의 `연결 해제` 단추에 **확인창이 없었다.** 한 번 누르면 그대로 끊기는데,
530
+ 되돌리는 데 **남의 손이 필요하다** — 매니저가 바로 잇지 못하고 회원에게 요청을
531
+ 보낸 뒤 **그 사람이 수락**해야 이어진다. 같은 창의 다른 되돌리기 어려운 일들
532
+ (고객 삭제·기기 삭제)은 진작 묻고 있었고 여기만 빠져 있었다. 말귀 쪽에서
533
+ 검수하다 실제로 눌러 라이브 고객의 회원 연결이 끊겼고 되돌리지 못했다.
534
+
535
+ `customers.detail.deleteConfirm` 이 "무엇이 함께 사라지고 되돌릴 수 없다" 를
536
+ 적는 것과 같은 결로 적었다 — **끊으면 어떻게 되는지**를 말한다.
537
+
538
+ 확인창 자체는 소비자가 붙인다. 코어에는 그 연결 흐름을 쓰는 코드가 **하나도
539
+ 없다**(실측: `customers.member.*`·`customers.link.*` 를 부르는 코어·놀이터 코드
540
+ 0곳, `manager_link_member`·`phone_hash` 를 아는 코어 코드 0곳). 사전만 코어 소유다.
541
+
542
+ ## 0.62.0
543
+
544
+ ### 더함 — `trainingStatus.homework.totalRate`
545
+
546
+ `전체 완료율` / `Overall rate`.
547
+
548
+ 숙제 진행현황 링 차트가 늘 `주간 완료율` 이라고 적는데 **그 숫자는 주간이 아니라
549
+ 전체다.** 소스가 그렇게 하기로 정해 둔 것이고(`get_b2c_homework_rate` 는 기간 인자를
550
+ 안 받고 `{total_items, done_items}` 한 줄만 돌려준다) 옆 구획들만 기간으로 저민다.
551
+ 말귀 쪽 실측: 같은 고객으로 주·9월·8월·7월·6월을 오가도 숙제만 38%·8건·3건 으로
552
+ 고정인데, 오늘의 듣기는 27%→4%, 문장훈련은 15→7분으로 바뀐다. 게다가 월 보기에서도
553
+ "주간" 이라고 적는다 — 바로 옆 문장훈련은 같은 자리에서 `월간 훈련시간 평균` 으로
554
+ 제대로 바뀐다. **숫자는 의도한 대로이고 낱말만 거짓말을 한다.**
555
+
556
+ 놀이터의 `RingChart`·`ChartSection` 시연 여섯 자리도 새 낱말로 옮겼다 — 그 시연이
557
+ 바로 이 화면을 흉내 낸 것이라 같은 거짓말을 하고 있었다.
558
+
559
+ `trainingStatus.homework.weeklyRate` 는 **아직 지우지 않는다.** 말귀가 그 열쇠로
560
+ 부르고 있어, 지금 지우면 라벨이 깨진다. 소비자가 옮긴 뒤에 걷는다.
561
+
562
+ ## 0.61.0
563
+
564
+ 말귀 쪽 세션이 숙제 보내기 화면을 만들며 사전에 요청한 두 갈래다. 사전은 코어
565
+ 소유라 소비자에서 못 건드린다.
566
+
567
+ ### 더함 — 고객 상세 '숙제 보내기' 낱말 넷
568
+
569
+ `customers.homework.send` · `sendSub` · `sendGo` · `sent`.
570
+
571
+ `개인 링크 생성` 단추가 `숙제 보내기` 로 바뀌면서 훈련 플랜을 고르는 창이 생겼다.
572
+ 그동안 소비자는 **있는 낱말을 빌려** 버티고 있었다 — 창 제목에
573
+ `trainingSettings.sendHomework.title`(훈련 설정 화면의 것), 행 단추에
574
+ `trainingSettings.toast.hwSend`(**`toast.` 아래** 있는 것). 뜻은 맞지만 남의 자리라,
575
+ 그쪽 문구를 정리하다 지우면 이쪽 단추 이름이 사라진다. 넷이 들어왔으니 그 빌림을
576
+ 걷을 수 있다.
577
+
578
+ ### 걷어냄 — 부르는 이가 없어진 낱말 다섯
579
+
580
+ `customers.homework.createPersonal`(→ `send` 로 대체) ·
581
+ `homework.manage.filterActive` · `filterExpired`(숙제 관리 갈래를 활성/만료에서
582
+ 신규·진행중·완료로 바꿔 `myHomework.segment.*` 를 나눠 쓴다) ·
583
+ `homework.status.done` · `notDone`(카드마다 달리던 완료/미완료 배지를 걷었다 —
584
+ 갈래가 이미 그 말을 하는데 배지를 하나씩 더 달아 목록이 배지밭이 됐다).
585
+
586
+ 코어·놀이터·소비자 넷을 다 훑어 부르는 곳이 없음을 확인했고, 열쇠를 변수로
587
+ 조립하는 자리(`t(\`...\${x}\`)`)도 따로 봤다 — 걸리는 것은 `customers.pageSize.*` 와
588
+ `notifications.homework.kind.*` 뿐이라 이 다섯과 무관하다.
589
+
590
+ `homework.status.sentFmt` 는 **그대로 둔다** — 카드 풋터의 '누가 · 언제' 자리로
591
+ 내려가 계속 쓴다.
592
+
593
+ **시킨 것보다 둘을 더 지웠다.** `homework.status.done` 은 ko 에만 `done` 이고
594
+ en 에는 `done_one`·`done_other` 다. `done` 만 지웠으면 **en 에 고아 둘**이 남는다.
595
+
596
+ ### 그물 — `test/i18nLocaleParity.test.ts`
597
+
598
+ 바로 그 고아를 잡는다. `i18nKeysExist` 는 **코어가 부르는** 열쇠만 보는데, 코어가
599
+ 안 부르고 **소비자만 부르는** 열쇠가 많아 한쪽 사전에만 올리거나 한쪽에서만 지워도
600
+ 아무 시험도 안 울었다. 폴백이 한국어라 **영어 화면에서만** 티가 난다.
601
+
602
+ 양방향으로 견주고, 복수 접미사를 벗겨 맞춘다. 지금은 예외 목록 없이 깨끗하다
603
+ (양쪽 2759개). 셋을 심어 확인했다 — ko 에서만 지우기 · en 에만 안 올리기 · 그리고
604
+ **접미사 벗기기를 죽이기**(거짓 양성 거르개: 죽이면 멀쩡한 복수형이 죄다 걸린다).
605
+
606
+ ### 고침 — 소비자 판 표
607
+
608
+ `RELEASE.md` 의 소비자 표가 0.59.0 에 멈춰 있었다. 0.60.0 을 내면서 빠뜨린 것을
609
+ `audit:consumers` 가 잡았다.
610
+
611
+ ## 0.60.0
612
+
613
+ 말귀 쪽 세션이 숙제 관리 화면을 손보다 걸린 네 건이다. 보고받은 값을 그대로
614
+ 믿지 않고 **놀이터에서 다시 재서** 확인한 뒤 고쳤다.
615
+
616
+ ### 고침 — **카드 머리가 ⋯ 단추 키를 따라 부풀었다**
617
+
618
+ `TrainingCard` 의 제목과 `topActions` 를 한 줄 flex 로 세워 두어, `topActions` 에
619
+ 흔히 들어가는 `Button size={40}`(⋯ 더보기)의 키를 **26px 짜리 제목 줄이 따라갔다.**
620
+ 제목 위아래로 빈 자리가 생기고 머리가 통째로 커졌다.
621
+
622
+ 머리를 두 칸 격자로 바꾸고 `topActions` 를 오른쪽 칸에서 **두 줄에 걸쳐** 세운다
623
+ (`align-self: start`). 실측(1440 폭):
624
+
625
+ | | 고치기 전 | 고친 뒤 |
626
+ |---|---|---|
627
+ | 머리 (단추 40px) | 66px | **52px** |
628
+ | 머리 (단추 32px) | 58px | **52px** |
629
+ | 머리 (단추 없음) | 52px | 52px |
630
+
631
+ 자리를 클래스가 아니라 **차례로** 짚는다 — `title`·`description` 은 문자열이 아니면
632
+ 소비자가 준 노드를 그대로 그리므로 코어 클래스가 없을 수 있다.
633
+ `.training-card__title-row` 는 `display: contents` 가 되었다(마크업은 그대로다).
634
+
635
+ ### 고침 — **한 줄에 선 카드 키가 안 맞았다**
636
+
637
+ 격자가 `align-items: flex-start` 라 카드가 안 늘어났고, `children` 이 비면
638
+ `TrainingCard` 가 `.training-card__body` 를 **아예 안 그려** 풋터를 바닥으로 밀 것이
639
+ 없었다. **두 자리가 다 있어야** 맞는다 — 실측:
640
+
641
+ | | 본문 있음 | 본문 없음 | 풋터 아래 |
642
+ |---|---|---|---|
643
+ | 고치기 전 | 205px | **135px** | 19 / 19 |
644
+ | 격자만 `stretch` | 205px | 205px | 19 / **89** |
645
+ | 풋터만 `margin-top: auto` | 205px | **135px** | 19 / 19 |
646
+ | 둘 다 | 205px | 205px | 19 / 19 |
647
+
648
+ ### 고침 — **접을 수 없는 목록에도 홑화살이 붙었다**
649
+
650
+ `TrainingCardGroup` 이 머리의 홑화살을 늘 그렸다. `onToggle` 이 없어도 단추는 나오고,
651
+ 눌러도 아무 일이 없으며, 낭독기에는 **"접기, 단추"** 로 읽혔다(WCAG 4.1.2).
652
+ `onToggle` 이 있을 때만 그린다 — **새 prop 은 두지 않았다.** "접기를 쓰는가" 를
653
+ `onToggle` 유무가 이미 말한다.
654
+
655
+ 말귀에서 실측: `TrainingCardGroup` 을 쓰는 **아홉 자리 중 여덟이 `onToggle` 을 안
656
+ 준다.** 즉 죽은 단추가 여덟 개 떠 있었다.
657
+
658
+ 곁들여 머리의 `cursor: pointer` 도 걷었다. 머리에는 `onClick` 이 아예 없어 **눌러도
659
+ 아무 일이 없는데 손 모양만 났다** — 손 모양이 거짓말을 하고 있었다. 접는 것은
660
+ 홑화살 단추이고 그쪽은 그대로다. (prop 주석의 "헤더 클릭/Enter 시 토글" 도 실제와
661
+ 맞게 고쳤다. 머리 전체를 누를 수 있게 만드는 것은 키보드 자리까지 새로 정해야 하는
662
+ 일이라 하지 않았다.)
663
+
664
+ ### 넓힘 — **`MenuTooltip` 의 열 머리글이 노드를 받는다**
665
+
666
+ `TooltipMenuColumn.title` 이 `string | null` 이라, 메뉴 맨 위에 "지금 상태"를
667
+ (대개 `Badge`) 세울 수 없었다. 소비자는 `MenuTooltip` **위에 형제로** 끼우고 좌우
668
+ 여백을 눈으로 맞추고 있었는데, 코어가 항목 여백을 바꾸면 머리글만 어긋난다.
669
+ `ReactNode` 로 넓혀 여백이 코어 한 곳으로 모인다. 문자열 쪽은 그대로 된다.
670
+
671
+ ### 그물
672
+
673
+ - `test/trainingCardLayout.test.ts` — ①②를 CSS 로 못박는다. **주석을 먼저 걷는다** —
674
+ 이 CSS 의 주석에는 까닭을 적느라 `align-items: flex-start` 가 글자로 들어 있어,
675
+ 그대로 읽으면 규칙이 되돌아가도 주석이 대신 통과시킨다.
676
+ - `test/trainingCardGroupChevron.test.tsx` — 없어지는 쪽만이 아니라 **`onToggle` 을
677
+ 주면 반드시 나오는지**도 함께 묻는다. 안 그러면 단추를 통째로 안 그려도 초록이 된다.
678
+ - `test/menuTooltipNodeTitle.test.tsx` — 노드를 실제로 넣어 그린다. 타입이 좁으면
679
+ `tsc` 가 먼저 울고, 그리는 쪽이 틀리면 이 시험이 운다.
680
+ - 놀이터에 "접을 수 없는 목록" 시연을 더했다 — 홑화살이 없고, 본문 있는 카드와
681
+ 없는 카드가 한 줄에서 키를 맞추는 것을 눈으로 볼 수 있다.
682
+
683
+ 여섯 가지를 심어 확인했다: `grid-row` 를 한 줄로 되돌리기 · 격자를 `flex-start` 로 ·
684
+ 풋터의 `margin-top` 빼기 · 홑화살을 늘 그리기 · 타입을 `string` 으로 좁히기 · 그리고
685
+ **`align-items: stretch` 를 주석에만 적어 두기**(글자 훑기였다면 여기서 헛통과한다).
686
+ 전부 빨개졌고 되돌리면 초록이다.
687
+
688
+ ## 0.59.0
689
+
690
+ ### 고침 — **`applyFontScale()` 이 아무 일도 안 하고 있었다**
691
+
692
+ 코어 CSS 는 소비자 앱과 섞이지 않게 `.ihabdevteam-core-scope` 로 가둔다. 그런데
693
+ 코어가 함께 내주는 `applyFontScale()` 은 **`<html data-font-scale="large3">`** 를 단다 —
694
+ 스코프보다 **위**다. 프리픽스가 그것을 `.ihabdevteam-core-scope [data-font-scale=…]`
695
+ 라는 **후손** 선택자로 바꾸어, 그 조합에서는 **아예 안 걸렸다.**
696
+
697
+ 실측(놀이터): `<html>` 에 걸어도 `--text-body-2-size` 가 16px 그대로였고, 스코프
698
+ **안쪽** 원소에 걸었을 때만 그 아래가 22px 이 됐다. 즉 **앱 전체 글자 크기 설정이
699
+ 죽어 있었다.**
700
+
701
+ `data-theme` 은 예전에 **똑같은 이유로 죽었다가 고쳐졌는데**, `data-font-scale` 만
702
+ 그 갈래에 빠져 있었다. 같은 자리에 넣는다 — 이제 세 꼴이 다 나간다:
703
+ 조상(`[속성] .scope`) · 자신(`.scope[속성]`) · 후손(`.scope [속성]`).
704
+
705
+ 고친 뒤: `large1`·`large2`·`large3` 가 각각 18·20·22px 로 먹는다.
706
+
707
+ **JS 쪽 시험은 이 결함이 있는 내내 초록이었다** — `applyFontScale()` 이 속성은 제대로
708
+ 달았기 때문이다. 반쪽만 재고 있었다. `test/scopePrefixAncestor.test.ts` 가 나머지
709
+ 반쪽(빌드가 실제로 내는 선택자)을 잰다.
710
+
711
+ 토큰 전용 빌드(`CORE_CSS_NO_PREFIX=1`)는 프리픽스를 안 걸어 늘 멀쩡했다 — 그래서
712
+ `tokens.css` 만 쓰는 소비자에게는 이 결함이 **보이지 않았다.** `data-theme` 때와 같다.
713
+
714
+ ### 재고 남긴 것 — `large3` 에서 잘리는 자리
715
+
716
+ 살아난 배율로 141개 화면을 훑으니 여섯 곳에서 글자가 잘린다. 놀이터 시연 상자와
717
+ 일부러 줄인 미리보기, 그리고 표 칸(`table-field`)이다 — 글자 간격(1.4.12) 훑기에서
718
+ 나온 것과 같은 갈래라 REVIEW.md 에 함께 적어 둔다.
719
+
720
+ ## 0.58.0
721
+
722
+ ### 고침 — **안 보이는 블록이 문서를 가로로 넓히고 있었다**
723
+
724
+ `ReportDocument` 는 쪽을 나누려고 `.report-doc__measure` 라는 그림자 블록을 둔다.
725
+ `opacity: 0` · `z-index: -10` 이라 눈에는 안 보이는데, **A4 안쪽 폭(688px)으로 고정된
726
+ `position: absolute`** 다. 그런데 담은 상자에 위치 기준이 없어서 그것이 **페이지에
727
+ 매달려** 문서를 그만큼 넓히고 있었다 — 320px 폭(=400% 확대)에서 문서가 509px 이 되어
728
+ 페이지가 가로로 굴렀다(WCAG 1.4.10 Reflow, AA).
729
+
730
+ `.report-doc` 를 위치 기준으로 둔다. 그 상자의 가로 스크롤이 삼켜 문서는 320 그대로다.
731
+ **눈에 보이는 것은 하나도 안 바뀐다** — 그 블록은 원래 안 보인다.
732
+
733
+ ### 놀이터 — 320px 에서 가로로 구르던 여섯 곳
734
+
735
+ 디자인 시스템의 문서 자체가 400% 확대에서 양방향 스크롤을 요구하고 있었다.
736
+ `bi-logo-grid`·`bi-symbol-grid`·`tp-usage-grid` 의 고정 바닥(`minmax(280px…)` 등)을
737
+ `min(…, 100%)` 로 두고, `pcbg-select`·`pcbg-text-input` 의 `min-width` 도 같게 했다.
738
+ 코드 조각(`pre.pg-code`)은 **제 안에서 구르게** 한다 — 2차원 내용은 1.4.10 이 허용한다.
739
+
740
+ `ComponentBox` 의 세로칸에 `max-width:100%; min-width:0` 을 준다. 0.51.0 에서는 이
741
+ 칸이 shrink-to-fit 이라 `width:100%` 인 아이가 0 으로 접혔는데, 여기서는 **반대로**
742
+ A4 한 장 같은 넓은 아이가 담은 칸(192px)을 뚫고 나갔다.
743
+
744
+ 실측: 141개 화면 중 여섯 → **0**.
745
+
746
+ ### 도구 — `scripts/reflow-320.browser.js`
747
+
748
+ 320px 에서 문서가 구르는지 훑는다. **`innerWidth` 를 쓰면 안 된다** — 내용이 넘치면
749
+ 그 값도 함께 커져서(600px 상자를 심었더니 622 가 됐다) 넘친 만큼을 빼면 늘 0 이
750
+ 나온다. `documentElement.clientWidth` 를 쓴다.
751
+
752
+ ## 0.57.0
753
+
754
+ ### 고침 — 메뉴가 열려도 **아무도 그걸 몰랐다**
755
+
756
+ `Header` 의 유틸리티 메뉴와 `BottomNav` 의 "더보기" 는 단추에 `aria-expanded` 도
757
+ `aria-controls` 도 없고, 열리는 판은 **역할 없는 맨 `div`** 를 `document.body` 끝에
758
+ 포털로 붙였다. 낭독기로는 **누르면 아무 일도 안 일어난 것처럼** 들린다 — 열린 것도,
759
+ 무엇이 열렸는지도 알 수 없다(WCAG 4.1.2).
760
+
761
+ 같은 훅(`useAnchoredMenu`)을 쓰는 `Dropdown` 은 이미 `aria-haspopup`·`aria-expanded`
762
+ ·`aria-controls` 를 갖추고 있었다. **한 저장소 안에서 규약이 갈려 있던 것**이라 본을
763
+ 따라 맞췄다. 열린 판에는 `role="group"` 과 이름을 준다.
764
+
765
+ `role="menu"` 는 쓰지 않는다 — 안쪽이 `menuitem` 이 아니라서 **반만 맞은 트리**가 된다.
766
+
767
+ i18n 열쇠 `header.utilMenu`·`nav.moreMenu` 가 늘었다(ko·en).
768
+
769
+ **눈으로도 손으로도 안 드러나는 갈래다** — Esc 로 닫히고 바깥클릭으로도 닫히니
770
+ 마우스·키보드로는 멀쩡했다.
771
+
772
+ ### 고침 — hover 로 뜬 그림판을 **Esc 로 걷을 수 없었다**
773
+
774
+ `WordSearchResultBlock` 이 낱말 위에 띄우는 `WordImageTooltip` 은 포인터를 치워야만
775
+ 사라졌다. WCAG 1.4.13(Content on Hover or Focus)은 **포인터를 옮기지 않고도 물릴 수
776
+ 있어야** 한다고 한다. 공개 훅 `useEscToClose` 로 공유 Esc 스택에 얹는다 — 겹쳐 있어도
777
+ 맨 위 하나만 닫힌다. (`WordImageTooltip` 자신은 닫을 길이 없다. 상태를 여는 쪽이
778
+ 들기 때문이고, 그래서 고침도 여는 쪽에 있다.)
779
+
780
+ ### 훑개 — 겉면의 **움직임**을 재는 갈래를 더했다
781
+
782
+ `scripts/overlay-sweep.browser.js` 가 이제 열림 뒤의 동작도 잰다: 모달이면 포커스가
783
+ 안으로 들어가는가 · Esc 로 닫히는가 · 배경이 잠기는가.
784
+
785
+ **재는 법에서 두 번 속았다.** ① 단추에 **미리 포커스를 주었더니** 그 자리의 hover
786
+ 툴팁이 스스로 떠서 그것을 "열린 겉면" 으로 셌다 — 멀쩡한 자리 열넷이 걸렸다.
787
+ ② 애니메이션으로 사라지는 겉면을 "사라졌나" 로만 보아 **팝업 넷이 "Esc 로 안 닫힘"**
788
+ 으로 걸렸다. 진짜 Esc 키로도 그래서 도구 탓인 줄 알았는데, `popup--closing` 이
789
+ 붙어 있는 것을 보고 갈랐다 — 판이 숨어 있으면 그 제거 타이머가 분 단위로 늦는다.
790
+ 이제 **닫히는 중인가**를 본다. 고친 뒤 141개 화면·겉면 25개에서 **0**.
791
+
792
+ ## 0.56.0
793
+
794
+ ### 고침 — 달력을 **연 채로** 재니 포커스가 안 보이는 자리 셋
795
+
796
+ 이미 "포커스 표시 0" 으로 확인했던 갈래인데, 그건 **평평한 화면만** 잰 것이었다.
797
+ 겉면을 열고 다시 재니 `DatePicker` 안에서 셋이 나왔다 — 짚어도 겉모습 다섯 값이
798
+ 전부 그대로였다.
799
+
800
+ · **트리거** — `.date-picker--open .date-picker__trigger` 와
801
+ `.date-picker__trigger:focus` 가 **같은 선언을 나눠 쓰고** 있었다. 열린 꼴이
802
+ 이미 포커스 꼴이라, 달력이 열린 채로 짚어도 달라지는 것이 없다. 키보드만 쓰는
803
+ 사람은 포커스가 단추에 있는지 격자에 있는지 알 수 없다.
804
+ · **연월 단추** — 밑바탕이 `border`·`box-shadow` 를 `!important` 로 죽여
805
+ `.dropdown__trigger:focus-visible` 이 거는 두 가지가 사라지고, 그 규칙의
806
+ `outline: none` 만 남았다.
807
+ · **오늘 칸** — `.date-picker__day--today` 의 `outline` 이 전역 `:focus-visible`
808
+ 고리와 특이도가 같은데 뒤에 선언돼 이겼다. **그 칸만** 표시가 없었다
809
+ (다른 날짜는 전역 고리가 정상으로 걸린다 — 실측으로 갈랐다).
810
+
811
+ 셋 다 시스템의 `--focus-ring` 토큰으로 고리를 되돌린다. 새 색을 만들지 않았다.
812
+ 고친 뒤 141개 화면·겉면 26개·자리 933개를 판정해 **0**.
813
+
814
+ ### 훑개 고침 — **재기 전에 포커스를 빼내야 한다**
815
+
816
+ `focus-visible` 훑개가 짚기 전 모습을 재면서 **이미 포커스가 가 있던 자리**를
817
+ 그대로 찍고 있었다. 겉면을 여는 클릭이 그 단추나 첫 입력칸을 짚어 놓기 때문에,
818
+ 전·후가 같아져 **멀쩡한 것이 걸린다** — 쿠폰 입력칸이 그렇게 헛걸렸다.
819
+ 화면 밖 그릇으로 포커스를 먼저 빼내고 잰다. 전이(transition)를 끄고 재라는 것도
820
+ 문서에 적었다(색이 흐르는 중에 재면 "후" 가 아직 옛 값이다).
821
+
822
+ ## 0.55.0
823
+
824
+ ### 고침 — 이름 없는 대화상자 다섯
825
+
826
+ `Tooltip` 의 **기본 역할이 `dialog`** 다. 그것을 감싸는 컴포넌트가 `ariaLabel` 을
827
+ 안 주면 조용히 이름 없는 대화상자가 된다 — 낭독기가 "대화상자" 라고만 읽어
828
+ **무슨 창인지 알 수 없다.** 다섯 곳이 그랬다: `DescriptionTooltip`(제목이 있으면
829
+ 그것을 이름으로) · `TextBoxTooltip`(라벨) · `TrainingTooltip` · `TrainingStep` 이
830
+ 감싸는 본문 툴팁 · `PassCard` 의 메뉴.
831
+
832
+ i18n 열쇠 `tooltip.description`·`tooltip.textbox`·`tooltip.training`·`tooltip.menu`
833
+ 가 늘었다(ko·en).
834
+
835
+ ### 어떻게 찾았나 — **훑기가 아무것도 열어 보지 않고 있었다**
836
+
837
+ 지금까지의 브라우저 훑기는 화면을 **가만히 놓고** 쟀다. 그래서 141개 화면을 다
838
+ 돌아도 눌러야 뜨는 겉면은 **한 번도 안 지나갔다.** 세어 보니 공개 컴포넌트 128개
839
+ 중 12개가 그런 사각이었고(`Dialog`·`Toast`·팝업 넷·툴팁 둘 …), 그것들이야말로
840
+ 포커스·모달·이름 같은 탈이 몰리는 자리다.
841
+
842
+ `scripts/overlay-sweep.browser.js` 로 단추를 눌러 겉면 32개를 열어 재니 넷이
843
+ 나왔다. 소스로 세니 **다섯**이었다 — `PassCard` 의 메뉴는 놀이터에서 열리지 않는다.
844
+ **여는 훑기가 소스 훑기를 대신하지 못한다.** 서로 못 보는 것이 다르다.
845
+
846
+ ### 이번에 배운 것 두 가지
847
+
848
+ **하나 — 아무 단추나 누르면 소스가 바뀐다.** 놀이터에는 PassCardBg·PromoBannerBg
849
+ 화면의 저장 단추가 `/api/save-*-theme` 로 **소스의 색값을 되쓰는** 개발 전용
850
+ 플러그인이 있다. 처음 훑을 때 그 단추까지 눌러 세 파일의 색이 덮어써졌다
851
+ (`git checkout` 으로 되돌렸다). 훑개에 거르개를 두고, 훑은 뒤 `git status` 를
852
+ 보라고 적어 두었다.
853
+
854
+ **둘 — 대화상자의 이름은 본문 글자에서 오지 않는다.** 조작부와 같은 계산을 쓰면
855
+ 안에 글자가 있다는 이유로 늘 "이름 있음" 이 되어, 처음 쓴 훑개가 결함을 심어도
856
+ 조용했다. `aria-label`·`aria-labelledby`·`title` 만 센다.
857
+
858
+ 그리고 **`aria-modal` 이 없는 대화상자는 탈이 아니다** — 모달 아닌 대화상자는
859
+ 옳다(`DatePicker` 도 툴팁들도 일부러 그렇다). 그것까지 잡았다가 멀쩡한 일곱
860
+ 화면이 빨갛게 나와 걷어냈다.
861
+
862
+ ## 0.54.0
863
+
864
+ ### 고침 — 훈련 팝업이 **모달이라는 말을 아무 데도 안 하고 있었다**
865
+
866
+ `TrainingPagePopup` 이 `role="dialog"` 는 바깥 `div` 에, `aria-modal="true"` 는
867
+ **역할 없는 안쪽 판**에 걸고 있었다(두 곳). `aria-modal` 은 대화상자 역할이 붙은
868
+ 바로 그 요소에서만 뜻이 있어서, 그 말은 아무 데도 닿지 않았다 — 낭독기는 이것을
869
+ 그냥 대화상자로 읽고 **가상 커서가 뒤 화면으로 새어 나간다.**
870
+
871
+ Tab 가둠은 `useFocusTrap` 이 따로 하고 있어서 **키보드로는 멀쩡해 보였다.** 그래서
872
+ 눈으로도 손으로도 안 드러났다 — 소스를 훑어야 나오는 갈래다.
873
+
874
+ 둘을 한 요소에 모은다. `Dialog` 와 `Popup` 은 원래 옳았고, `DatePicker` 는 일부러
875
+ 모달이 아니다(가둠 없이 Esc 로 닫힌다) — 그대로 둔다.
876
+
877
+ **브라우저로는 못 쟀다.** 이 저장소의 브라우저 도구가 내주는 접근성 트리는 모달
878
+ 여부를 안 드러내고(고친 꼴과 안 고친 꼴을 나란히 심어 견주었더니 둘 다 그냥
879
+ `dialog` 였다), 놀이터는 이 팝업을 코드로만 보여 주고 실제로 그리지 않는다.
880
+ 그래서 잣대를 소스에 두었다.
881
+
882
+ ## 0.53.0
883
+
884
+ ### 고침 — 가로로 굴려야 다 보이는데 **키보드로는 굴릴 수 없었다**
885
+
886
+ `ReportDocument` 의 바깥 상자(`.report-doc`)는 A4 한 장(210mm ≈ 794px)을 담는데,
887
+ 좁은 자리에서는 그보다 좁아 `overflow-x: auto` 로 굴린다. 그런데 그 안에는
888
+ **포커스가 갈 곳이 하나도 없다** — 마우스나 손가락이 없으면 오른쪽 절반을 영영
889
+ 못 본다(WCAG 2.1.1). 놀이터 141개 화면을 훑어 나온 **유일한** 자리였다
890
+ (실측: 겉 560px 에 안 794px).
891
+
892
+ **넘칠 때만** 포커스를 받게 한다. 늘 걸어 두면 안 넘칠 때도 빈 탭 자리가 하나
893
+ 생긴다. 포커스를 받을 때는 `role="group"` 과 이름(`report.previewRegion`)을 함께
894
+ 준다 — 이름 없이 포커스만 주면 낭독기가 문서 전체를 읽는다.
895
+
896
+ i18n 열쇠 `report.previewRegion` 이 늘었다(ko·en).
897
+
898
+ **이 갈래는 브라우저에서 못 쟀다.** 판이 숨어 있으면 렌더 주기가 안 돌아
899
+ `ResizeObserver` 가 아예 안 울리고, 자동화 도구는 화살표의 기본 스크롤 동작을
900
+ 못 만든다(대조군으로 심은 평범한 `div[tabindex=0]` 도 안 굴렀다). 그래서 잣대를
901
+ jsdom 에 두었다 — 크기를 직접 박고 관찰자 콜백을 손으로 부른다.
902
+
903
+ ### 고침 — 소스에 박힌 날바이트 때문에 **git 이 이 파일을 바이너리로 쳤다**
904
+
905
+ `ReportDocument.tsx` 안에서 쪽 나눔 기준을 만들 때 구분자로 `0x00`·`0x01` 을
906
+ **날바이트 그대로** 박아 두었다. 그 탓에 git 이 이 파일을 바이너리로 쳐서 diff 가
907
+ 안 보이고, `grep` 도 이 파일을 통째로 건너뛴다 — 고친 사람의 변경을 볼 수도
908
+ 찾을 수도 없다(이번 검수에서 실제로 세 번 헛짚었다).
909
+
910
+ `\u0000`·`\u0001` 이스케이프로 바꾼다. **돌리는 값은 한 바이트도 다르지 않다.**
911
+
912
+ ### 놀이터 — 절 제목 671곳이 **제목이 아니었다**
913
+
914
+ `PageParagraph` 의 제목이 `<div class="h3">` 였다. 보이기는 제목인데 낭독기에는
915
+ 제목이 아니라, **141개 화면 어디서도 절 사이를 뛰어 다닐 수 없었다** — 화면당
916
+ 진짜 제목은 `PageTitle` 의 `h1` 하나뿐이었다. `PageTitle` 이 `<span class="h1">`
917
+ 이던 것과 같은 결이다.
918
+
919
+ `<h2 class="h3">` 으로 바꾼다. **보이는 것은 하나도 안 바뀐다** — 코어의
920
+ `:where(h1..h6).h3` 이 받아 준다. 12개 화면에서 상자 7,080개를 견주어 **한 개도
921
+ 다르지 않음**을 확인했다.
922
+
923
+ Typography·Icons 가 `PageParagraph` 안에 손으로 적어 둔 `<h4>` 13개도 `h3` 으로
924
+ 내렸다(`h2` 다음이 `h4` 라 단계를 건너뛰고 있었다). 그것들은 등급 클래스가 없어
925
+ 브라우저 기본값 16px/700 으로 그려지고 있었는데, 이제 `h4b` 등급을 받아
926
+ 17px/600 이다 — 디자인 시스템 놀이터가 제 등급을 안 쓰고 있던 자리다.
927
+
928
+ 실측: 진짜 제목 230 → **900개**, 제목이 하나뿐인 화면 141 → **0곳**,
929
+ 단계 건너뜀 **0**.
930
+
931
+ ### 도구 둘 — `focus-visible` · `heading-order`
932
+
933
+ `scripts/focus-visible.browser.js` 는 **키보드로 짚은 자리가 보이는지**를 잰다
934
+ (WCAG 2.4.7). 141개 화면에서 **자리 3,626개를 판정해 0** — 이 갈래는 깨끗하다.
935
+ `getComputedStyle` 을 그냥 견주면 안 된다: `outline-style: none` 인데 색만 바뀌는
936
+ 것을 변화로 세면, 표시가 하나도 없는 자리까지 초록이 된다(처음 쓴 판이 실제로
937
+ 그래서 결함을 심어도 조용했다). **칠해지는 것만** 센다.
938
+
939
+ `scripts/heading-order.browser.js` 는 제목 뼈대를 잰다. `.h1`~`.h6` 은 이
940
+ 시스템에서 **글자 크기 등급**이라 값·단위·낱자에도 붙는다 — 클래스로 세면 901개가
941
+ 나오는데 제목은 671개뿐이었다. 그래서 **요소 이름만** 본다.
942
+
943
+ ## 0.52.0
944
+
945
+ ### 고침 — 0.51.0 이 **스스로를 0.50.0 이라 말하며 나갔다**
946
+
947
+ `CORE_VERSION` 은 손으로 적는 값이고 `test/coreVersion.test.ts` 가 지킨다. 그런데
948
+ 지난 판에서 **관문을 다 돌린 뒤에 판을 올려서** 그 시험이 새 값을 못 보고 지나갔다.
949
+ 0.51.0 을 받은 소비자가 `CORE_VERSION` 을 읽으면 `'0.50.0'` 이 나온다 — 이 상수가
950
+ 있는 까닭 자체가 "판 번호를 믿을 수 없다" 는 것이라, 이건 없느니만 못하다.
951
+
952
+ 값을 맞추고, **구멍도 막았다**: `scripts/publish-check.sh` 가 맨 앞에서 판 번호
953
+ 시험을 돌린다. 배포 직전인 그 자리가 마지막 문이다. 판만 올리고 상수를 두어 보니
954
+ EXIT=1 로 막힌다.
955
+
956
+ ### 고침 — 누를 수 있는 별이 **손가락에 비해 작았다**
957
+
958
+ `Rating` 의 medium 별이 20×20 이고 칸 사이가 2px 이라, 별 다섯이 **22px 간격**으로
959
+ 붙어 있었다. WCAG 2.5.8(Target Size (Minimum), AA)은 24×24 를 요구한다.
960
+
961
+ **별 그림은 그대로 두고 누르는 상자만** 24×24 로 넓혔다(간격 26 — 손잡이가 겹치지
962
+ 않는다). `--interactive` 에만 걸어 **읽기 전용 별은 하나도 안 바뀐다**. 실측:
963
+ 누를 수 있는 medium 이 108→128px, 읽기 전용 medium·small·large 는 108·78·148 그대로.
964
+
965
+ ### 도구 — `scripts/target-size.browser.js`
966
+
967
+ 누르는 자리 크기를 놀이터에서 훑는다. `getBoundingClientRect()` 만 보면 틀린다 —
968
+ 손잡이를 투명 유사요소로 넓혀 둔 자리가 있기 때문이다(`.top-banner__dot::after` 가
969
+ 그렇다: 점은 6px 인데 짚이는 자리는 12×24). 그래서 작아 보이는 것만 골라 한가운데에서
970
+ 사방으로 **짚어 본다**(`elementFromPoint`). 규격이 정한 예외(사이가 벌어진 것,
971
+ 문장 속 인라인)도 함께 가른다.
972
+
973
+ **남은 자리 하나는 일부러 안 고쳤다** — `TopBanner` 의 안 고른 점이 12×24 다.
974
+ 24 로 넓히려면 점 사이를 6px→18px 로 벌려야 하고, 그건 회원앱 첫 화면에 보이는
975
+ **눈에 띄는 디자인 변경**이다. 겹치게만 넓히면 숫자는 채워도 잘못 누르는 일은
976
+ 그대로라 하지 않았다. 사람이 정할 자리로 남긴다.
977
+
978
+ ## 0.51.0
979
+
980
+ ### 고침 — 붙일 자리가 없는 툴팁이 **한 점에 포개졌다**
981
+
982
+ `Tooltip` 은 `anchorRect` 도 `anchorEl` 도 없으면 "글 흐름 안에 그린다"고 정해
983
+ 두었다 — 포털도 안 쓰고 위치 스타일도 안 붙인다. 그런데 `.tooltip-content` 가
984
+ `position: absolute` 라, **흐름에서 빠지는 건 그대로**였다. 그래서 anchor 없이
985
+ 쓰는 툴팁을 여럿 늘어놓으면 담은 줄이 폭도 높이도 0 이 되고, 툴팁들이 **모두
986
+ 같은 자리에** 겹쳐 하나만 보였다.
987
+
988
+ 실측(놀이터 `#tooltip` 미리보기, 1440×900): 넷 다 `735,671` 한 점 · 담은 줄
989
+ `0×0`. 고친 뒤 `557 / 649 / 728 / 832` 로 나란히 서고 줄은 `356×26`.
990
+
991
+ `InfoTooltip` · `DescriptionTooltip` · `TextBoxTooltip` · `TrainingTooltip` 이
992
+ 같은 길을 탄다. **anchor 를 주고 쓰던 자리는 하나도 바뀌지 않는다** — 코어 안의
993
+ 사용처 일곱 곳은 전부 anchor 를 주고 있고, 그중 hover 로만 여닫는 둘도
994
+ `useLayoutEffect` 로 재고 있어 anchor 없는 중간 그림은 화면에 닿지 않는다.
995
+
996
+ `tooltip-content--inline` 표시가 새로 붙는다(anchor 가 없을 때만).
997
+
998
+ ### 놀이터 — 예시 넷이 **폭 0 으로 접혀 아무것도 안 보였다**
999
+
1000
+ `ComponentBox` 의 세로칸은 제 폭을 내용에 맞춘다(shrink-to-fit). 거기에 제 폭을
1001
+ `width:100%` 로 잡는 아이를 넣으면 서로를 물어 0 으로 떨어진다. `Divider`(둘),
1002
+ `UpdateAlert`, `LandingBottomSticky`, `TrainingStepAudioPlayer` 가 그랬다 —
1003
+ `TrainingStepAudioPlayer` 는 단추 셋이 18px 로 눌려 있었다.
1004
+
1005
+ 넷 다 `rowClassName="pbox--full"` 을 준다. 새 도구
1006
+ `scripts/collapse-sweep.browser.js` 로 141개 차림을 다시 훑어 0 을 확인했다.
1007
+
1008
+ ## 0.50.0
1009
+
1010
+ ### 고침 — `Toast` 가 **저 혼자 사라지는데 멈출 길이 없었다**
1011
+
1012
+ 알림이 `duration` 뒤에 스스로 사라지는데, 읽는 사람이 그 시계를 멈출 방법이
1013
+ 없었다. 천천히 읽는 사람에게는 **읽을 수 없는 것과 같다**(WCAG 2.2.1 — 시간
1014
+ 제한은 멈추거나 늘릴 수 있어야 한다). `duration={0}` 으로 아예 안 사라지게 하는
1015
+ 길은 전부터 있었지만 **그건 부르는 쪽이 미리 정하는 것**이지 읽는 사람의 몫이
1016
+ 아니었다.
1017
+
1018
+ 이제 **머무르면 멈춘다** — 마우스를 올려도, 키보드 포커스가 들어가도. 다시
1019
+ 움직일 때는 **남은 시간부터** 돈다. 처음부터 다시 세면 마우스가 스쳐 지나가기만
1020
+ 해도 알림이 두 배로 머문다.
1021
+
1022
+ `useToastLifecycle` 이 `pause`·`resume` 를 함께 돌려준다(더해지기만 했다).
1023
+ `duration={0}` 은 예전과 같다.
1024
+
1025
+ 실측: `duration` 2000ms 인 알림에 마우스를 올린 채 4초가 지나도 남아 있고,
1026
+ 떼면 사라진다.
1027
+
1028
+ ### 심어 보다가 안 것
1029
+
1030
+ `사라짐걸기` 의 가드를 `ms <= 0` 에서 `ms < 0` 으로 바꿨다. 0 은 "지금 사라져라"
1031
+ 이지 "사라지지 마라" 가 아니다. **다만 그 자리에 그물은 안 뒀다** — 남은 시간이
1032
+ 정확히 0 인 상태를 시험으로 만들어 보려다 못 만들었기 때문이다(그 전에 시계가
1033
+ 먼저 터진다). 못 잡는 칸을 두느니 안 두는 편이 낫다.
1034
+
1035
+ ## 0.49.0
1036
+
1037
+ ### 고침 — `PaymentPlan` 에 화면에 뜨는 한국어 둘이 박혀 있었다
1038
+
1039
+ - `tier.label` 이 없을 때의 대체말 `'이용권'`
1040
+ - 프로모션 배지의 `` `오픈 특가\n…` ``
1041
+
1042
+ 둘 다 사전으로 옮겼다(`payment.plan.tierFallback` · `payment.plan.openPromo`).
1043
+ 영어는 `Plan` · `Launch offer`.
1044
+
1045
+ **같은 파일의 정규식 `/오픈\s*특가/` 는 그대로 둔다.** 그건 화면에 뜨는 글이 아니라
1046
+ 앱이 넘겨준 **데이터를 읽는** 것이라 한국어여야 한다 — 그 둘을 가르는 것이 이번
1047
+ 고침의 요점이다.
1048
+
1049
+ 코어의 다른 한글 리터럴 132개는 살펴보고 두었다. 도메인 타입 리터럴
1050
+ (`ContentType = '단어' | '이미지' | …`), 데이터 정규화 값(`['ok','true','맞음','정답']`),
1051
+ 구조화된 폴백(`{ i18nKey, fallback }`), 개발 모드 경고문이라 번역 대상이 아니다.
1052
+
1053
+ ## 0.48.0
1054
+
1055
+ ### 고침 — `DatePicker` 의 **요일이 박혀 있었다**
1056
+
1057
+ `const DOW = ['일', '월', …]` 이 파일에 그대로 있었다. 같은 저장소의 `Calendar` 는
1058
+ 진작 `t('calendar.dow')` 를 쓰고 있었고 사전에는 영어(`Sun`·`Mon`…)도 들어 있었는데,
1059
+ 날짜 고르개만 그것을 안 썼다. **영어 화면에서 요일만 한국어로 떴다.**
1060
+
1061
+ 같은 열쇠를 쓰게 했다. 브라우저 실측: 영어로 바꾸면 `Sun…Sat`, 판 이름
1062
+ `Choose a date`, 이전 달 `Previous month`.
1063
+
1064
+ ### 놀이터 — 언어 전환이 **코어까지 바꾼다**
1065
+
1066
+ 이 두 결함(요일이 박힌 것, `datePicker.chooseDate` 가 사전에 없던 것)이 오래
1067
+ 안 드러난 까닭이 여기 있었다. 놀이터의 영어 전환이 **놀이터 제 문구만** 바꿨다 —
1068
+ `playground_lang=en` 인데 `<html lang>` 은 `ko` 였고 코어 컴포넌트는 계속 한국어를
1069
+ 보였다. **문서에서 코어의 영어를 볼 방법이 없었다.**
1070
+
1071
+ 이제 전환이 `saveLocale()` 과 `i18n.changeLanguage()` 도 함께 부른다. 코어의
1072
+ i18n 은 그 둘을 진작 내보내고 있었다 — 놀이터가 안 쓰고 있었을 뿐이다.
1073
+
1074
+ ## 0.47.0
1075
+
1076
+ ### 고침 — 영어 화면의 달력이 **"날짜 선택"** 이라고 떴다
1077
+
1078
+ 0.35.0 에서 `DatePicker` 판에 이름을 주며 `t('datePicker.chooseDate', '날짜 선택')`
1079
+ 을 넣었는데, **사전에 올리지 않았다.** 이 저장소는 `t()` 에 폴백을 함께 주므로
1080
+ 화면은 멀쩡해 보인다 — 다만 **폴백이 한국어**라 영어로 보는 사람에게만 한국어가
1081
+ 뜬다. 한국어로 개발하는 동안에는 영영 안 드러난다.
1082
+
1083
+ `ko`·`en` 양쪽에 올렸다(`날짜 선택` / `Choose a date`).
1084
+
1085
+ ### 그물 — `i18nKeysExist`
1086
+
1087
+ 코어가 부르는 열쇠 391개를 두 사전과 대조한다. 복수형은 i18next 가 접미사로
1088
+ 다루므로(`foo_one`·`foo_other`) 그것까지 보고 판단한다.
1089
+
1090
+ 세우면서 한 번 헛돌았다. **배열을 잎으로 안 다뤄** `calendar.dow` 같은 열쇠가
1091
+ 없다고 나왔다 — 요일 일곱을 배열로 담아 `returnObjects: true` 로 통째로 쓰는
1092
+ 자리다. 배열 안까지 파고들면 `calendar.dow.0` 만 남고 정작 부르는 이름은 사라진다.
1093
+ 그 거짓 양성 넷을 걷어 냈다.
1094
+
1095
+ ## 0.46.0
1096
+
1097
+ ### 고침 — 닫힌 콤보박스가 **없는 id 를 가리키고 있었다**
1098
+
1099
+ `TextField` 에 `suggestions` 를 주면 `aria-controls` 를 **늘** 걸어 두었는데, 후보
1100
+ 목록은 **열려 있고 걸린 후보가 있을 때만** 그려진다. 그래서 닫혀 있는 동안(그리고
1101
+ 열렸어도 걸린 것이 없을 때) 그 속성이 **아무 원소도 안 가리켰다.**
1102
+
1103
+ 없는 id 를 가리키면 낭독기는 그 관계를 못 풀고 조용히 버린다 — 오류도 경고도
1104
+ 없다. 놀이터 라우트 141개에서 `aria-labelledby`·`aria-controls`·
1105
+ `aria-activedescendant` 가 실재하는 것을 가리키는지 훑다가 찾았다(그 하나뿐이었다).
1106
+
1107
+ 이제 **목록이 실제로 그려졌을 때만** 가리킨다. `aria-activedescendant` 도 같은
1108
+ 조건을 탄다. 시험이 "렌더 조건과 같은 식인가" 까지 본다 — 조건이 갈라지면 다시
1109
+ 같은 일이 난다.
1110
+
1111
+ ## 0.45.0
1112
+
1113
+ ### 고침 — 툴팁 안 **비활성 칸에서 Tab 이 멈춘 것처럼 보이던** 자리
1114
+
1115
+ `Tooltip` 이 Tab 가둠에 쓸 고르개를 제 안에 박아 두었고, 공용 것과 두 군데가
1116
+ 달랐다 — `disabled` 를 안 뺐고 `a[href]` 대신 `[href]` 를 썼다. 그러면 툴팁 안
1117
+ **마지막 칸이 비활성일 때** 되돌아온 포커스가 아무 데도 안 가서 **멈춘 것처럼**
1118
+ 보인다.
1119
+
1120
+ 이 자리는 `focusTrapSingleSource` 의 예외 주석에 **"생기는 날 고칠 자리"** 로
1121
+ 적혀 있었다. 오늘 고친다 — 고르개만 공용 `FOCUSABLE_SELECTOR` 로 바꾼다.
1122
+
1123
+ **가두는 방식은 그대로다.** 툴팁은 갈 곳이 없을 때 Tab 을 **막지 않는다**(막으면
1124
+ 툴팁 안에 갇힌다). 공용 훅은 다이얼로그라 반대로 막는다 — 그 차이는 남긴다.
1125
+
1126
+ ## 0.44.0
1127
+
1128
+ ### 안쪽 — **아무도 안 쓰는 CSS 규칙 94개(544줄)를 지웠다.** 화면은 그대로다
1129
+
1130
+ `coreCssDeadRules` 가 죽은 클래스 97개를 기준선으로 얼려 두고 있었다. 그 주석에는
1131
+ **"지우기 전에 반드시 소비자 쪽을 확인할 것"** 이라 적혀 있었는데, 이 저장소에는
1132
+ 그 눈이 없었다(모노레포를 떼면서 잃었다).
1133
+
1134
+ 이번에 **소비자 세 곳(말귀 본·어드민, 소리방향성)의 소스 1,170 파일과 놀이터를
1135
+ 모두 읽어** 대조했다.
1136
+
1137
+ | | 개수 |
1138
+ |---|---:|
1139
+ | 소비자가 실제로 얹어 쓴다 | **32** |
1140
+ | 놀이터가 쓴다 | 1 |
1141
+ | 어디서도 안 쓴다 | **64** |
1142
+
1143
+ 그 주석이 옳았다 — 32개는 정말 소비자만 쓰고 있었고, 그건 그대로 뒀다. 이름을
1144
+ 조립해 쓰는 경우(`payment-plan__${x}`)도 접두사로 따로 확인했는데 하나도 없었다.
1145
+
1146
+ 남은 64개를 쓰는 규칙 94개를 지웠다(선택자 두 개는 죽은 쪽만 떼어 내고 산 쪽은
1147
+ 남겼다). 기준선은 97 → **33**.
1148
+
1149
+ **화면이 그대로인 것은 실측으로 확인했다.** 놀이터 라우트 140개에서 요소의 상자
1150
+ 크기를 지우기 전후로 재어 견줬고 **달라진 라우트가 하나도 없다.** 그럴 수밖에
1151
+ 없다 — 코어도 소비자도 놀이터도 그 이름을 마크업에 쓰지 않으니 그 규칙들은 애초에
1152
+ **맞을 수 있는 원소가 없었다.**
1153
+
1154
+ ## 0.43.0
1155
+
1156
+ ### 고침 — **마우스로만 되던 자리 넷**을 키보드로도 되게 한다
1157
+
1158
+ `<div onClick>` 은 눈으로는 멀쩡하다. 그런데 Tab 으로 닿지 않고, Enter·Space 로
1159
+ 눌리지 않고, 낭독기는 그것이 누를 수 있는 것인 줄도 모른다. **마우스가 없으면 그
1160
+ 기능이 없는 것과 같다.**
1161
+
1162
+ 코어 전체에서 단추가 아닌 요소에 붙은 `onClick` 열아홉을 세어, 실제로 마우스
1163
+ 전용인 넷을 골랐다:
1164
+
1165
+ | 자리 | 무엇이 안 됐나 |
1166
+ |---|---|
1167
+ | `LoadTablePopup` 격자 카드 | 저장한 표를 고를 수 없다 |
1168
+ | `LoadTablePopup` 목록 줄 | 〃 |
1169
+ | `Calendar` 방문 표시 | 방문을 눌러 열 수 없다 |
1170
+ | `LandingBottomSticky` | 띠 전체가 누름인데 닿지 않는다 |
1171
+
1172
+ `utils/pressable` 한 벌로 `role`·`tabIndex`·Enter/Space 를 붙인다. 넷이 같은 여섯
1173
+ 줄을 각자 적는 대신 한 곳만 둔다.
1174
+
1175
+ **누를 것이 없으면 아무것도 안 붙인다.** `onVisitClick` 이나 `onClick` 을 안 주면
1176
+ `role` 도 `tabIndex` 도 없다 — 아무 일도 안 하는 자리에 Tab 이 멈추는 것이 더 나쁘다.
1177
+
1178
+ ### 안 고친 열다섯 — 왜 아닌지
1179
+
1180
+ - **배경 클릭 둘**(`Dialog` · `Tooltip`): 배경을 눌러 닫는 것은 마우스 편의이고,
1181
+ 키보드 길은 Escape 다. 배경에 Tab 을 멈추면 오히려 방해다.
1182
+ - **전파만 막는 감싸개 여섯**: `onClick={e => e.stopPropagation()}` 은 동작이 아니다.
1183
+ - **listbox 항목 하나**(`TextField` 의 제안): `aria-activedescendant` 로 다루는
1184
+ 자리라 항목에 `tabIndex` 를 주면 안 된다.
1185
+ - **안쪽에 진짜 단추가 있는 자리 하나**(`TrainingBlockButton` 의 더보기): 눌림이
1186
+ 거품처럼 올라와 이미 키보드로 된다.
1187
+ - **못 하는 일을 알려 주는 감싸개 둘**(`WordSearchResultBlock` · `PhonemeSelectBlock`):
1188
+ 안쪽 단추가 비활성일 때 왜 못 누르는지 알려 주려고 클릭을 받는다. 비활성 단추는
1189
+ 포커스를 못 받으므로 키보드로는 그 안내가 안 나온다 — **고치려면 설계를 정해야
1190
+ 한다**(`aria-disabled` 로 바꾸고 포커스를 남길지). 값만 적어 두고 두었다.
1191
+
1192
+ ## 0.42.0
1193
+
1194
+ ### 고침 — 한 화면에 `Tabs` 가 둘이면 **탭이 남의 판을 가리켰다**
1195
+
1196
+ 판 이름표를 `tabs__tab-${key}` 로 **열쇠만** 써서 지었다. `overview` · `usage` 처럼
1197
+ 흔한 열쇠를 쓰는 `Tabs` 가 한 화면에 둘 이상이면 **같은 id 가 여럿 생긴다.**
1198
+ 그러면 브라우저는 `aria-controls` 를 **첫 번째** 것으로 푼다 — 두 번째 탭이 첫
1199
+ 번째 탭의 판을 가리키고, 낭독기는 엉뚱한 내용을 읽는다. 눈으로는 아무 표시가
1200
+ 없어서 알 길이 없다.
1201
+
1202
+ 놀이터 라우트 141개를 돌며 중복 id 를 세다가 찾았다 — `tabs__tab-overview` 가
1203
+ **넷**이었다.
1204
+
1205
+ `useId()` 로 인스턴스마다 가른다(`tabs__tab-_r_2_-overview`). 실측으로 중복 0,
1206
+ Tabs 넷이 모두 제 판을 가리킨다. **이 id 는 밖에서 붙잡는 곳이 없다** — 코어
1207
+ CSS 는 `--active` 클래스를 쓰고, 놀이터·시험·소비자 어디서도 이 id 를 안 쓴다.
1208
+
1209
+ ## 0.41.0
1210
+
1211
+ ### 고침 — 없는 아이콘 이름 셋이 **빈 네모**로 그려지고 있었다
1212
+
1213
+ `tag` · `trash` · `lock` 이 `STATIC_LUCIDE_ICONS` 에 없었다. 없는 이름을 주면
1214
+ 조용히 실패한다 — 예외도 오류도 없고 `Icon` 이 뜻 없는 **빈 둥근 사각형**을
1215
+ 그린다. 화면은 멀쩡해 보이고 아무도 모른다.
1216
+
1217
+ **이 갈래는 두 번째다.** 목록 안에 이미 이런 주석이 남아 있었다 —
1218
+ "SearchSummary 의 기본 상태 아이콘 — 없어서 안 그려지고 있었다(2026-08-28)".
1219
+ 그때는 사람이 눈으로 찾았고 그물은 안 세웠다.
1220
+
1221
+ 이번에는 **놀이터 라우트 141개를 돌며 콘솔을 주워** 찾았다. 개발 모드 경고는
1222
+ 있었지만 그 경고는 **그 화면을 열어야** 울린다. 이제 `test/iconNamesExist` 가
1223
+ 소스만 읽어 판올림 전에 잡는다 — 글자로 적힌 아이콘 이름이 목록에 있는지 본다.
1224
+ `Badge type="dot"` 의 제 낱말집(`check|remove|minus`)은 lucide 를 안 타므로 딴말집
1225
+ 목록에 이유와 함께 적어 두었고, 그 목록이 낡으면 우는 칸도 두었다.
1226
+
1227
+ ### 놀이터 — 이름 없는 컨트롤 열둘을 고쳤다
1228
+
1229
+ 같은 훑기가 `SoundCompass` 와 `copyText` 의 이름 없는 슬라이더·입력칸을 찾았다.
1230
+ `PanelRow label` 은 **눈에만 보이는 이름**이라 낭독기에는 안 이어진다.
1231
+
1232
+ 세는 데서 **두 번 틀렸다.** 처음엔 코드 예시(백틱) 안을 세어 35개가 나왔고(실제
1233
+ 화면은 6개), 걸러 낸 뒤에는 정규식이 **화살표 함수의 `>`** 에서 끊겨 여섯을 더
1234
+ 놓쳤다. 브라우저 훑기가 그 여섯을 다시 잡아 줬다. 141개 라우트에서 경고 7 → 0.
1235
+
1236
+ ## 0.40.0
1237
+
1238
+ ### 고침 — `SegmentedControl` 과 `Rating` 이 **`radiogroup` 이라 말해 놓고 화살표가 안 먹었다**
1239
+
1240
+ `role="radiogroup"` 을 붙이면 그 키를 만들겠다고 **약속한 것**이다. 붙여 놓고 안
1241
+ 만들면 **안 붙인 것보다 나쁘다** — 낭독기가 "라디오 그룹, 5 중 3" 이라 읽어 주니
1242
+ 사람은 화살표를 누르는데 아무 일도 안 일어난다. 그냥 단추 여럿이었다면 적어도
1243
+ Tab 으로 옮길 줄은 알았을 것이다. 둘 다 `<button>` 을 `role="radio"` 로 덮어쓴
1244
+ 꼴이라 네이티브의 도움도 못 받고 있었다.
1245
+
1246
+ 놀이터 실측:
1247
+
1248
+ | | 칸 | Tab 자리 (전 → 후) | ArrowRight |
1249
+ |---|---:|---|---|
1250
+ | `SegmentedControl` | 2 | **2 → 1** | 안 움직임 → 0→1 |
1251
+ | `Rating` | 5 | **5 → 1** | 안 움직임 → 2→3 |
1252
+
1253
+ 이제 **Tab 자리는 하나**(고른 칸)이고 안은 화살표로 다닌다. 라디오에서는 **고름이
1254
+ 포커스를 따라간다** — 화살표로 옮기면 그 자리가 곧 선택이다. `Home`·`End` 는 처음과
1255
+ 끝. `Tab` 은 그대로 흘려보낸다(막으면 무리 안에 갇힌다).
1256
+
1257
+ **둘이 한 군데서 다르다.** `SegmentedControl` 은 끝에서 **돌고**(탭 성격이라 이어지는
1258
+ 편이 낫다), `Rating` 은 **안 돈다** — 1과 5는 끝이라는 뜻이 있어서, 5에서 오른쪽을
1259
+ 눌러 1로 떨어지면 별점이 뒤집힌다. `SegmentedControl` 은 못 고르는 칸(`disabled`)도
1260
+ 건너뛴다.
1261
+
1262
+ `Rating` 의 읽기 전용 모드는 그대로다 — 그건 라디오 무리가 아니라 `role="img"` 로
1263
+ 묶음이 값을 읽어 준다.
1264
+
1265
+ **공개 API 는 안 바뀐다.** 눈에 보이는 것도 그대로고, 키보드로 쓸 때만 달라진다.
1266
+
1267
+ ## 0.39.0
1268
+
1269
+ ### 안쪽 — **역할이 어긋난 자리를 바로잡았다.** 화면과 동작은 그대로다
1270
+
1271
+ 폴더를 표준 웹앱 꼴(`pages/`·`features/`·`services/`·`stores/`)로 옮기려다
1272
+ 재 보고 그만뒀다. 이 저장소는 웹앱이 아니라 **npm 으로 나가는 컴포넌트
1273
+ 라이브러리**이고, `exports` 맵이 소스 경로에 묶여 있어 **폴더 배치가 곧 공개
1274
+ API** 다. 말귀만 해도 깊은 경로로 약 580곳에서 가져간다(components 207 ·
1275
+ utils 122 · hooks 72 · types 45 · Training 57 · Popup 42 · Word 18 ·
1276
+ context 15). 옮기면 리팩토링이 아니라 파괴적 변경이 된다.
1277
+
1278
+ 그래서 **경로는 고정하고** 역할이 어긋난 자리만 안에서 바로잡았다.
1279
+
1280
+ - **의존 역류 세 자리를 아래로 돌렸다.** 훅이 `createElement` 로 화면을 만들던
1281
+ 자리, 그리고 타입 하나 때문에 `utils` 가 `components`·`hooks` 를 올려다보던
1282
+ 두 자리. 함께 아는 모양을 아래 계층으로 내리고 **옛 자리에서 재수출**한다.
1283
+ - **컴포넌트가 직접 소리를 받아 오던 45줄**을 `utils/audioPeaks` 로 뺐다.
1284
+ - **`Panel`** 을 그릇(`Panel`·`PanelGrid`)과 줄(`PanelRow`·`PanelRowDevice`)로
1285
+ 갈랐다. 524줄 → 134 + 416. `Panel` 이 전부 재수출한다.
1286
+ - **훅 둘**(`useAnchoredMenu` · `useSoundCompassExam`)이 `hooks` 로 왔다.
1287
+ 옛 깊은 경로는 껍데기로 남는다. 자세한 것은 CONSUMING.md 2-1.
1288
+ - 쓰이지 않던 `src/stories` 둘을 지웠다(Storybook 은 설정된 적이 없다).
1289
+
1290
+ ### 더해짐 — `FilterPanelSection.labelInfo`
1291
+
1292
+ 라벨 옆 "?" 설명을 **요소 대신 내용으로** 준다: `labelInfo={{ title, items }}`.
1293
+ 그리는 일은 `FilterPanel` 이 `FilterSectionInfo` 로 한다.
1294
+
1295
+ 까닭은 계층이다. 섹션을 만들어 주는 훅이 `createElement(FilterSectionInfo, …)`
1296
+ 로 요소를 빚고 있었는데, 그러려면 **훅이 컴포넌트를 올려다봐야** 한다.
1297
+ `labelAction`(ReactNode)은 **그대로 둔다** — 호출부가 제 요소를 넣고 싶을 때가
1298
+ 있다. 둘 다 있으면 `labelAction` 이 이긴다.
1299
+
1300
+ ### 없어진 것은 없다
1301
+
1302
+ 공개 표면은 더해지기만 했다 — 위 `labelInfo`, `utils/audioPeaks`,
1303
+ `components/PanelRow`, 그리고 `hooks` 배럴의 훅 둘. 옮긴 파일은 전부 옛 자리가
1304
+ 재수출하므로 지금 쓰는 import 는 한 줄도 안 고쳐도 된다.
1305
+
1306
+ ### 그물 셋을 새로 세웠다
1307
+
1308
+ `layerDirection`(계층 역류 — 타입만 가져오는 것도 막는다) ·
1309
+ `componentsDontFetch`(컴포넌트 안의 통신 호출) ·
1310
+ `hookHome`(훅이 제자리에 있는가 — 낡은 예외가 방패가 되는 것까지 검사).
1311
+ 여덟 갈래로 결함을 심어 전부 빨개지는 것을 확인했다.
1312
+
1313
+ ## 0.38.0
1314
+
1315
+ ### 더해짐 — `PageTitle` 에 **뒤로가기**(`onBack`)
1316
+
1317
+ 창 대신 **깊이**로 펴는 화면에는 돌아갈 길이 있어야 한다. 그런데 그 화살표를 놓을
1318
+ 자리가 없어서, 소비자는 `title` 에 JSX 를 넣어 끼웠다. 그러면 `useDocumentTitle`
1319
+ 이 문자열을 못 받아 **문서 제목이 앱 이름에 머문다**(WCAG 2.4.2) — 그것을 메우려
1320
+ `documentTitle` 을 또 줘야 했다. 뒤로가 필요한 화면이 늘 때마다 이 두 가지를 매번
1321
+ 다시 하는 일이다.
1322
+
1323
+ `onBack` 을 주면 제목 **앞에** 화살표를 그린다. 이때 `title` 은 문자열 그대로여도
1324
+ 되므로 **문서 제목이 저절로 붙는다** — `documentTitle` 을 따로 줄 일이 없어진다.
1325
+ 이름은 `backLabel`, 기본은 `t('common.back')`.
1326
+
1327
+ **세로 가운데가 이 변경의 반이다.** 화살표를 제목과 같은 줄에 그냥 놓으면 글자
1328
+ **기준선**에 앉는다. `vertical-align:middle` 도 답이 아니다 — 기준선에서 x-높이의
1329
+ 절반만큼 올린 자리이지 글자 상자의 가운데가 아니다. 소비자 앱 실측으로 2.5px
1330
+ 어긋났고 사람이 바로 알아봤다. 제목 줄은 이미 `display:flex; align-items:center`
1331
+ 이므로, 화살표를 **h1 의 형제**로 두면 저절로 맞는다. 놀이터에서 재니 화살표·제목·
1332
+ 뱃지가 모두 같은 값, 어긋남 **0.00px** 이다.
1333
+
1334
+ **h1 *안*에는 넣지 않는다.** 넣으면 h1 이 flex 가 되어 제목의 마디마디가 각각 칸이
1335
+ 되고 **사이 공백이 사라진다** — 소비자 앱에서 `<span>홍길동</span> 고객님` 이
1336
+ '홍길동고객님' 이 됐다. 형제로 두면 줄의 flex 가 자리를 맞추면서도 제목 안쪽은
1337
+ 건드리지 않아, 소비자가 `title` 에 무엇을 넘기든 그대로 읽힌다.
1338
+
1339
+ 아이콘 색은 `--color-text-primary` 를 쓴다. `--color-black` 은 다크에서 이름과 실제
1340
+ 색이 뒤집히는 이름이라 새 코드에서는 역할 이름을 쓴다(`tokens.css` 주석).
1341
+
1342
+ ### 고침 — `PageTitle` 의 `titleAction` 이 **받아만 놓고 안 그렸다**
1343
+
1344
+ `titleAction: _titleAction` 으로 받아서 버리고 있었다. CSS(`.page-title__title-action`)
1345
+ 까지 있는데 그리는 곳만 없었다 — **타입에는 보이니 쓰는 쪽은 됐다고 믿고, 화면에는
1346
+ 아무것도 안 나온다.** 뱃지 다음 자리에 그린다.
1347
+
1348
+ ### 고침 — `ConfirmPopup` 의 **곁 단추가 닫힘을 못 골랐다**
1349
+
1350
+ `extraAction` 은 확인·취소 왼쪽에 놓이는 곁 단추다. 뜻으로 보면 **파괴적인 일이
1351
+ 오는 자리**다 — 삭제, 연결 해제. 알림 창의 주 단추(파랑)는 '그대로 두기' 여야
1352
+ 하므로 되돌릴 수 없는 일은 여기밖에 갈 곳이 없다.
1353
+
1354
+ 그런데 이 자리에 오는 일은 **실패한다.** 서버가 막고, 설정이 꺼져 있고, 권한이
1355
+ 없다. 예전에는 누르면 **무조건 닫혔다** — 실패해도 창이 사라져 까닭을 그 자리에
1356
+ 못 보였다. 사람은 눌렀는데 아무 말 없이 창이 없어진 것만 본다. 무엇이 막혔는지,
1357
+ 어디를 켜야 하는지는 영영 모른다.
1358
+
1359
+ `onConfirm` 과 같은 계약으로 맞췄다:
1360
+
1361
+ | | 예전 | 지금 |
1362
+ |---|---|---|
1363
+ | `onClick` | `() => void` | `(close) => void \| Promise<void>` |
1364
+ | 닫힘 | 무조건 | `closeOnClick`(기본 `true`) 또는 `close()` |
1365
+ | 비동기 | 안 기다림 | `await` 한 **뒤에** 닫는다 |
1366
+ | 두 번 눌림 | 두 번 나감 | 재진입 막음(확인 단추와 같다) |
1367
+ | 따로 비활성 | 없음 | `disabled` |
1368
+
1369
+ **쓰던 곳은 안 깨진다.** 인자를 안 받는 `onClick` 은 그대로 돌고, `closeOnClick`
1370
+ 기본이 `true` 라 지금까지의 동작이 그대로다.
1371
+
1372
+ 시험 여섯을 붙였다(`test/confirmPopupExtraAction.test.tsx`). 예전 코드로 되돌리면
1373
+ 그중 다섯이 운다. 하나는 양쪽에서 통과해야 하는 것 — 인자 없는 옛 호출이 그대로
1374
+ 도는지 보는 시험이다.
1375
+
1376
+ 놀이터 `ConfirmPopup` 에 **일부러 실패하는** 시연을 뒀다. 눌러 보면 창이 남고 그
1377
+ 자리에 까닭이 뜬다.
1378
+
1379
+ ### 더해짐 — `kakaoLink` 문구 여섯
1380
+
1381
+ 소셜 로그인이 같은 이메일의 기존 계정에 자동으로 붙었을 때, 그것을 알리고 떼어 낼
1382
+ 길을 주는 창에 쓴다. `ko` · `en` 둘 다 넣었다.
1383
+
1384
+ `title` · `message` · `messageWithEmail`(`{{email}}`) · `keep` · `unlink` ·
1385
+ `unlinkFailed`.
1386
+
1387
+ ### 고침 — 단어표를 **종이로 뽑으면** 글자가 작아지고 볼드가 풀렸다
1388
+
1389
+ 화면에서는 멀쩡한데 인쇄 미리보기만 그랬다. 새 창으로 열거나 HTML 로 내려받아도
1390
+ 같았다 — 셋이 같은 문서를 쓴다.
1391
+
1392
+ 원인은 CSS 스코핑이었다. 이 꾸러미의 규칙은 전부 `.ihabdevteam-core-scope`
1393
+ **아래**에 산다(`dist/components.esm.css` 2,404개, `dist/ihabdevteam-core.css`
1394
+ 121개). 화면은 앱 셸의 `<body class="ihabdevteam-core-scope">` 가 받쳐 주는데,
1395
+ 단어표 인쇄는 **숨은 iframe 에 문서를 손으로 짓는다.** 그 body 에 클래스가
1396
+ 없었다. `document.styleSheets` 를 통째로 베껴 넣으니 CSS 는 들어 있는데 선택자가
1397
+ 하나도 매치를 못 하는 상태였다 — 글자는 브라우저 기본 크기로, 굵기는 normal 로.
1398
+
1399
+ `captureHtml()` 이 짓는 `<body>` 에 클래스를 단다. `data-theme` 을 html 에 얹는
1400
+ 것과 같은 결이다.
1401
+
1402
+ 실측 확인(놀이터에서 `captureHtml` 이 지은 문서를 iframe 에 띄우고 잰 값 —
1403
+ `font-size` / `font-weight`):
1404
+
1405
+ | 클래스 | 화면 | 종이(고치기 전) | 종이(고친 뒤) |
1406
+ |---|---|---|---|
1407
+ | `h1b` | 32px / 800 | **16px / 400** | 32px / 800 |
1408
+ | `h2b` | 24px / 600 | **16px / 400** | 24px / 600 |
1409
+ | `h3b` | 20px / 600 | **16px / 400** | 20px / 600 |
1410
+ | `h5b` | 15px / 600 | **16px / 400** | 15px / 600 |
1411
+
1412
+ 글꼴도 같이 떨어졌다 — `Pretendard Variable` 에서 브라우저 기본
1413
+ (`Apple SD Gothic Neo`) 으로. 고친 뒤에는 넷 다 화면과 똑같다.
1414
+
1415
+ **지킴이를 문서 짓는 자리로 옮겼다.** 앱 쪽 시험은 `index.html` 두 개만 봐서
1416
+ 손으로 짓는 문서를 못 잡았다. 문서를 지어 내는 코드가 여기 있으니, "새 문서는
1417
+ 스코프 클래스를 단다" 는 시험도 여기에 둔다 — `captureHtml` 의 결과 문자열을
1418
+ 보므로 브라우저 없이 돈다. 문서를 짓는 자리는 이 꾸러미 안에 이 한 곳뿐이다
1419
+ (인쇄·새 창·내려받기가 모두 `captureHtml` 을 거친다).
1420
+
1421
+ ## 0.37.0
1422
+
1423
+ ### 되돌림 — **0.24.0~0.32.0 의 색 변경을 전부 되돌린다**
1424
+
1425
+ 사용자의 결정이다: **밤새 한 색 수정을 모두 되돌리고, 앞으로 색은 코어가 스스로
1426
+ 판단해 바꾸지 않는다.**
1427
+
1428
+ 되돌린 근거는 명암비가 틀렸다는 것이 아니다. 잰 값은 그대로 맞다. **그 숫자로
1429
+ 무엇을 할지가 코어가 정할 일이 아니었다.** 소비자 쪽에서 제 화면의 색을 되돌려도
1430
+ 코어가 스스로 바꾼 값이 남아 화면이 원래대로 안 돌아왔고, 그것이 이 되돌림의
1431
+ 직접 계기다.
1432
+
1433
+ CSS 스물셋을 0.22.0 나무와 글자 그대로 맞췄다 — ActionCard · Badge · BulkBar ·
1434
+ Button · Calendar · CodeBox · DatePicker · Dropdown · FileUpload ·
1435
+ HearingDeviceInfo · LandingHeader · Panel · DeviceListPopup · RingChart ·
1436
+ SearchSummary · Slider · StatChart · SummaryStrip · Table · Tabs · TextField ·
1437
+ Toolbar · PhonemeSelectBlock. `tokens.css` 에서는 `--focus-ring` 을
1438
+ `var(--color-main)` 으로 되돌렸다.
1439
+
1440
+ 실측 확인(놀이터 `#foundation-pieces`):
1441
+
1442
+ | 자리 | 0.36.0 | 지금 |
1443
+ |---|---|---|
1444
+ | ActionCard cta (main) | #1e5a8f | **#499cdf** |
1445
+ | ActionCard cta (sub) | #257e76 | **#4dcdc0** |
1446
+ | ActionCard 설명문 | #64748b | **#96a4b8** |
1447
+ | `--focus-ring` | #418ecd | **#499cdf** |
1448
+
1449
+ ### 남긴 것 — 색이 아닌 것들
1450
+
1451
+ `CORE_VERSION`(0.26.0) · 꾸러미 메타(0.23.0/0.23.1) · `initTheme()` 선택자
1452
+ (0.28.0/0.29.0) · `Dialog` 포커스 가둠과 복귀(0.33.0/0.34.0) · `DatePicker`
1453
+ 키보드 접근과 격자 이동(0.35.0/0.36.0) · 개발 모드 경고(0.31.0/0.31.1).
1454
+
1455
+ **0.27.0 의 어두운 테마 수리도 남긴다.** 값으로 보면 색이지만, 되돌리면 다크에서
1456
+ 글자만 뒤집히고 지면은 흰 채로 남던 상태로 돌아간다 — 다크를 실제로 쓰는 소비자가
1457
+ 있다. 간접 토큰 여덟의 해동과 `--color-text-main`/`--color-text-sub` 정의를 그대로
1458
+ 둔다(컴포넌트에서는 이제 안 쓴다 — 놀이터 결정 페이지만 쓴다).
1459
+
1460
+ ### 걷어냄 — 색 관문 셋
1461
+
1462
+ `audit:contrast` · `audit:surface-as-text` · `audit:theme-tokens` 를 스크립트와
1463
+ 함께 지웠다(`color-roles.cjs`, `surface-as-text.baseline.json` 포함).
1464
+
1465
+ **문을 두면 다음 사람이 그 숫자를 맞추려고 색을 또 만진다.** 색은 이 저장소가
1466
+ 정할 것이 아니므로 문을 두지 않는다. 재는 것은 남겼다 —
1467
+ `scripts/contrast-sweep.browser.js` 는 놀이터 콘솔에서 부르는 도구이고 아무것도
1468
+ 막지 않는다. 숫자가 필요하면 재서 **사람에게 보이고, 정하는 것은 사람이 한다.**
1469
+
1470
+ CONSUMING.md 3-6 의 명암비 표는 남겼다 — 잰 값은 사실이다. 다만 **그것이 코어의
1471
+ 방침이 아니라는 것**을 그 자리에 적었다.
1472
+
1473
+ ### 알아 둘 것 — 되돌리면 다시 나타나는 것
1474
+
1475
+ 되돌린 자리 가운데 다크에서 눈에 띄던 것이 하나 있다. `SummaryStrip` 의 값 글씨는
1476
+ 제 색이 없어 바깥에서 물려받는데, 어두운 지면에서 **실측 1.02** 로 사실상 안
1477
+ 보였다(0.29.1 에서 고쳤던 것). 이제 그 상태로 돌아간다. 고칠지는 사람이 정한다.
1478
+
1479
+ ## 0.36.0
1480
+
1481
+ ### 더해짐 — `DatePicker` 격자를 **화살표로 다닌다**
1482
+
1483
+ 0.35.0 에서 달력 **안으로** 들어가는 길을 놨는데, 들어간 다음이 남아 있었다.
1484
+ 서른한 날이 전부 Tab 자리라 달력 하나를 지나려면 Tab 을 서른 번 넘게 눌러야
1485
+ 했다 — 그건 격자가 아니라 목록을 지나는 꼴이다.
1486
+
1487
+ WAI-ARIA 격자 규칙대로 **Tab 은 격자를 하나로 세고**(roving tabindex) 안은
1488
+ 화살표로 다닌다:
1489
+
1490
+ | 키 | 하는 일 |
1491
+ |---|---|
1492
+ | ← → | 하루 |
1493
+ | ↑ ↓ | 한 주 |
1494
+ | Home / End | 그 주의 처음 · 끝 |
1495
+ | PageUp / PageDown | 한 달 |
1496
+ | Shift + PageUp/PageDown | 한 해 |
1497
+
1498
+ 판의 Tab 자리가 **서른셋에서 넷으로** 줄었다 — 이전 달, 연월 고르개, 다음 달,
1499
+ 그리고 활성일 하나. 옮김은 `Date` 에 맡겨 윤달·말일이 저절로 맞는다(9월 1일에서
1500
+ ← 를 누르면 8월 31일). `min`/`max` 를 넘어가면 **그 끝에 잡힌다** — 아무도 못
1501
+ 고르는 날에 서 있게 두지 않는다.
1502
+
1503
+ 달을 넘는 이동은 다시 그린 **뒤**라야 그 칸이 생기므로, 옮기고 나서 포커스를
1504
+ 준다. `Tab` 은 그대로 흘려보낸다 — 막으면 달력 안에 갇힌다.
1505
+
1506
+ ## 0.35.0
1507
+
1508
+ ### 고침 — `DatePicker` 달력에 **키보드로 닿을 수 없었다**
1509
+
1510
+ 같은 `useAnchoredMenu` 를 쓰는 두 메뉴가 키보드에서는 딴판이었다.
1511
+
1512
+ - `Dropdown` 은 제대로 한다 — 포커스를 트리거에 두고 `aria-activedescendant` 로
1513
+ 항목을 가리키며 화살표로 옮긴다(목록이니 그 길이 맞다).
1514
+ - `DatePicker` 는 **아무것도 안 했다.** 판이 `document.body` **끝**으로
1515
+ 포탈되므로, 트리거에서 Tab 을 눌러도 달력이 아니라 페이지의 다음 것으로 간다.
1516
+ 놀이터에서 재 보니 달력 첫 칸까지 **Tab 열일곱 번**이었다(그 뒤 페이지가 길어져
1517
+ 다시 재니 스물일곱). 그 수는 트리거 아래에 무엇이 얼마나 있느냐에 달렸다 —
1518
+ **열 수는 있는데 못 쓰는** 셈이다.
1519
+
1520
+ 이제 열면 포커스가 판 **안으로** 들어간다 — 고른 날, 없으면 오늘, 그것도 없으면
1521
+ 첫 칸. 닫으면 트리거로 돌려놓는다. 판은 `role="dialog"` 로 이름을 댄다
1522
+ (`날짜 선택`).
1523
+
1524
+ **`aria-modal` 은 안 붙였다.** 이 판은 바깥을 누르면 닫히므로 진짜 modal 이
1525
+ 아니다. 붙이면 낭독기에게 "나머지는 없다" 고 거짓말이 된다. 같은 까닭으로 Tab 도
1526
+ 가두지 않았다.
1527
+
1528
+ 되돌림은 **가려서** 한다 — 포커스가 **잃어버린 상태(`<body>`)일 때만** 트리거로
1529
+ 옮긴다. 이 판은 안 가두므로 Tab 으로 나갈 수 있고, 그 상태로 닫혔을 때 끌어오면
1530
+ 사람이 방금 옮겨 간 자리를 빼앗는다.
1531
+
1532
+ **아직 남은 것**: 화살표로 날짜를 옮기는 격자 이동(←→ 하루, ↑↓ 한 주,
1533
+ PageUp/Down 한 달)은 안 넣었다. 지금은 Tab 으로 날을 옮긴다.
1534
+
1535
+ ## 0.34.0
1536
+
1537
+ ### 고침 — `Dialog` 를 **언마운트로 닫으면** 포커스가 돌아오지 않았다
1538
+
1539
+ 0.33.0 에서 Tab 을 가둔 다음, 그럼 **나올 때는 어디로 가나** 를 봤다. 닫는 길이
1540
+ 둘인데 한쪽만 되돌리고 있었다:
1541
+
1542
+ | 닫는 방식 | 닫은 뒤 포커스 |
1543
+ |---|---|
1544
+ | `open={false}` 로 닫기 | 열던 단추 ✓ |
1545
+ | **언마운트**로 닫기 | **`<body>`** ✗ |
1546
+ | (견줌) `Popup` 언마운트 | 열던 단추 ✓ |
1547
+
1548
+ 되돌림이 `if (!open)` 갈래 안에 있었다. `{열림 && <Dialog open …/>}` 로 쓰면
1549
+ 그 갈래가 **아예 안 돈다** — 아주 흔한 꼴이다. 그러면 포커스가 `<body>` 로
1550
+ 떨어지고, 눈으로는 아무 표시가 없는데 다음 Tab 이 **페이지 맨 앞**으로 간다.
1551
+ 표 한가운데서 확인창을 열었다 닫은 사람이 사이드바부터 다시 훑어 내려와야 한다
1552
+ (WCAG 2.4.3). `Popup` 은 진작 치우개(cleanup)에서 되돌리고 있었다 — 같게 맞췄다.
1553
+
1554
+ **ESC 등록을 다른 효과로 뗐다.** 되돌림을 치우개로 옮기면 그 효과의 deps 가
1555
+ 곧 "언제 되돌리나" 가 된다. 한 효과에 두면 `handleClose` 가 바뀔 때마다 —
1556
+ 소비자가 `onClose={() => …}` 를 그대로 넘기면 **매 렌더마다** 바뀐다 — 치우개가
1557
+ 돌아 **열려 있는 채로 포커스를 도로 빼앗긴다.** 되돌림은 `open` 하나에만 매단다.
1558
+
1559
+ 공개 API 는 그대로다.
1560
+
1561
+ ## 0.33.0
1562
+
1563
+ ### 고침 — `Dialog` 안에서 Tab 이 **뒤 화면으로 새어 나갔다**
1564
+
1565
+ `aria-modal="true"` 는 **낭독기에게만** 통한다. 화면을 보며 Tab 을 누르는 사람은
1566
+ 그 표시를 못 보므로, 가둠이 없으면 마지막 칸 다음에서 그냥 걸어 나간다. 놀이터
1567
+ `#foundation-pieces` 에서 실제로 재 봤다 — '지우기' 다음 Tab 이 대화상자 **뒤에
1568
+ 가려진 입력칸**으로 갔다. 보이지도 않는 칸에 타자가 들어간다.
1569
+
1570
+ `useFocusTrap` 은 진작 있었고 `Popup` 은 쓰고 있었다. **`Dialog` 만 빠져 있었다.**
1571
+ 이제 건다. 끝에서 Tab 은 첫 칸으로, 첫 칸에서 Shift+Tab 은 끝으로 돌고, 가운데
1572
+ 에서는 브라우저에 맡긴다.
1573
+
1574
+ ### 바뀜 — `TrainingPagePopup` 의 사본 가둠을 훅으로 모았다
1575
+
1576
+ 훅 머리말은 "같은 열두 줄을 네 곳에서 모았다" 고 적어 뒀는데, 그 넷 중 하나는
1577
+ 실은 안 모여 있었다 — 제 안에 고르개를 따로 박아 두고(`FOCUSABLE_SELECTORS`,
1578
+ 공백 없는 그 변종) Tab 을 손으로 다뤘다. **갈 곳이 없을 때의 처리가 어긋나
1579
+ 있었다**: 사본은 그냥 흘려보내 배경으로 새고, 훅은 Tab 을 막는다. 다이얼로그는
1580
+ 막는 쪽이 맞으므로 훅을 따랐다. Escape 처리는 그 자리에 그대로 둔다.
1581
+
1582
+ 거기만 갖고 있던 `[aria-hidden="true"]` 거르개는 **훅으로 가져왔다.** 낭독기에
1583
+ 없는 것으로 해 놓고 Tab 으로는 들르게 하면 두 길이 어긋난다. 나머지 쓰는 곳에는
1584
+ 숨긴 포커스 대상이 없어 오늘로서는 아무것도 안 바뀐다.
1585
+
1586
+ `focusTrapSingleSource` 그물에 구멍이 하나 있었다 — **예외 목록에 있는 파일은
1587
+ 아예 안 본다.** 그래서 이유가 사라진 예외가 남으면 그 자리는 무엇을 해도 조용해
1588
+ 진다. "예외가 아직 필요한가" 를 재는 칸을 더했다.
1589
+
1590
+ ## 0.32.0
1591
+
1592
+ ### 고침 — **키보드 포커스 링**이 밝은 테마에서 기준에 못 미쳤다
1593
+
1594
+ 포커스가 어디 있는지 보이는 것은 마우스를 못 쓰는 사람에게 **길 전체**다. 그
1595
+ 링을 그리는 `--focus-ring` 이 `--color-main`(#499cdf)이었는데, 밝은 지면에서
1596
+ **2.90** · 카드 위 **2.83** 이다. 포커스 표시는 글자가 아니라 **비문자 UI**
1597
+ (WCAG 1.4.11)라 기준이 **3.0** 이고, 아슬하게 모자랐다.
1598
+
1599
+ 한 단계 짙은 `--color-deepMain`(#418ecd)으로 옮겼다 — 밝은 지면 **3.45** ·
1600
+ 카드 위 **3.36** · 어두운 지면 **4.55** 로 **두 테마 모두** 넘는다.
1601
+ (`--color-darkMain` 은 밝은 쪽 7.07 로 더 좋지만 어두운 쪽이 **2.22** 라 못
1602
+ 쓴다. 두 테마를 한 값으로 만족시키는 것은 `deepMain` 뿐이었다.)
1603
+
1604
+ **한 단계 차이라 브랜드 인상은 그대로다.** 다만 포커스 가능한 요소 **전부**의
1605
+ 링 색이 바뀌므로 눈에 보이는 변화다 — patch 가 아니라 minor 로 낸다.
1606
+
1607
+ `npm run audit:contrast` 가 이제 포커스 링도 잰다(기준 3, 두 테마). 되돌려
1608
+ 심어 봤고 양쪽 다 울었다 — `--color-main` → 밝은 쪽 2.90 에서 EXIT=1,
1609
+ `--color-darkMain` → 어두운 쪽 2.22 에서 EXIT=1.
1610
+
1611
+ ## 0.31.1
1612
+
1613
+ ### 더해짐 — `Slider` 도 **이름 없는 슬라이더**를 알린다
1614
+
1615
+ 0.31.0 과 같은 결이고, 여기가 더 함정이다. **이 컴포넌트에는 `label` prop 이
1616
+ 따로 있는데 그건 값 표기 방식**(`'count'|'percent'|'raw'`)**이지 이름이 아니다.**
1617
+ 이름은 `ariaLabel` 로 준다. 이름 자리가 둘로 갈려 있어 헷갈리기 쉽고, 실제로
1618
+ **놀이터 시연 서른넷이 전부 이 함정에 빠져 있었다.**
1619
+
1620
+ 이름 없는 슬라이더는 "슬라이더, 40" 으로만 읽힌다 — 무엇의 40 인지 알 수 없다.
1621
+
1622
+ - 조작할 수 없는 정적 표시(`onChange` 없음)는 **뺀다.** 그건 컨트롤이 아니라
1623
+ 그림이라, 거기까지 울리면 진행률 표시를 쓰는 자리마다 헛경고가 난다.
1624
+ - 운영 빌드에서는 아무 일도 하지 않는다. 한 번만 찍는다.
1625
+
1626
+ **공개 API 도 화면도 안 바뀐다** — 개발 모드 콘솔에만 나타나므로 patch 로 낸다.
1627
+
1628
+ ## 0.31.0
1629
+
1630
+ ### 더해짐 — `TextField` 가 **이름 없는 입력칸**을 개발 모드에서 알린다
1631
+
1632
+ **이름 없는 입력칸은 화면을 못 보는 사람에게 빈칸이다.** 놀이터를 훑다가 그런
1633
+ `<input>` 을 찾았는데, 컴포넌트는 멀쩡했고(`label` 이면 `<label htmlFor>`,
1634
+ `ariaLabel` 이면 `aria-label` 을 단다) **시연이 둘 다 안 준 것**이었다.
1635
+ 곧 소비자도 똑같이 빠뜨릴 수 있는 자리다.
1636
+
1637
+ `label` 도 `ariaLabel` 도 없으면 개발 모드에서 **한 번** 알린다:
1638
+
1639
+ ```
1640
+ [ihab/TextField] 이름이 없습니다 — label 이나 ariaLabel 중 하나를 주세요.
1641
+ placeholder 는 이름을 대신하지 못합니다.
1642
+ ```
1643
+
1644
+ - **운영 빌드에서는 아무 일도 하지 않는다.** 판별은 vite(`import.meta.env.DEV`)와
1645
+ webpack/Next(`process.env.NODE_ENV`)를 둘 다 본다.
1646
+ - **한 번만 찍는다** — 컴포넌트는 다시 그려지고, 그릴 때마다 찍으면 콘솔이 넘쳐
1647
+ 진짜 봐야 할 것이 묻힌다.
1648
+ - **타입으로는 안 막았다.** 둘 중 하나만 있으면 되는 규칙은 타입으로 표현하기
1649
+ 어렵고, 표현하더라도 소비자의 기존 코드를 한꺼번에 깨뜨린다.
1650
+
1651
+ `placeholder` 는 이름이 아니다 — 입력이 시작되면 사라지고, 스크린리더가 이름으로
1652
+ 읽어 주는 것도 보장되지 않는다.
1653
+
1654
+ **새 API 는 없다.** `aria-labelledby` 로 이름을 주는 길은 이 컴포넌트에 원래
1655
+ 없고(남는 props 를 input 에 안 흘린다), **요청이 없어 열지 않았다.**
1656
+
1657
+ ### 안쪽 — 개발 모드 판별을 한 곳으로
1658
+
1659
+ `utils/devWarn.ts` 로 옮겼다. 원래 `lucideLoader.ts` 안에만 있었는데, `process` 를
1660
+ `globalThis` 로 우회해야 하는 함정(안 그러면 소비 앱의 타입검사가 TS2591 로
1661
+ 막힌다)이 적혀 있어 두 곳에 두고 싶지 않았다. 공개 표면에는 안 올렸다.
1662
+
1663
+ ## 0.30.9
1664
+
1665
+ ### 고침 — `CodeBox` 의 문법 강조색 다섯 (그물이 못 보던 자리)
1666
+
1667
+ 이 색들은 **토큰이 아니라 CSS 에 박힌 hex** 라, 면 토큰을 글자에 쓰는지 보는
1668
+ `audit:surface-as-text` 가 **원리상 못 본다.** 훑기에서 되풀이 걸리는데도 매번
1669
+ 지나쳤다가 이번에 봤다.
1670
+
1671
+ `--color-code-bg`(#eff8ff) 위 12px 기준으로 넷이 모자랐다:
1672
+
1673
+ | 갈래 | 전 | 후 |
1674
+ |---|---:|---:|
1675
+ | `string` | 3.27 | 5.11 |
1676
+ | `number` | 2.96 | 5.06 |
1677
+ | `command` | 4.23 | 5.03 |
1678
+ | `operator` | 4.49 | 5.06 |
1679
+ | `comment` | 4.50 (선에 딱) | 5.05 |
1680
+
1681
+ 색조는 그대로 두고 검정 쪽으로 조금씩만 끌었다. `keyword` 는 4.95 라 그대로다.
1682
+
1683
+ **평지면만 보고 정했다가 한 번 모자랐다.** 강조 줄에는 10% 틴트가 한 겹 더
1684
+ 얹혀 지면이 `#deeffc` 가 되고 거기서 값이 0.4 쯤 떨어진다. 브라우저로 재어 그
1685
+ 겹을 찾았고 — **손 계산은 그 겹을 몰랐다** — 틴트 지면에서 4.6 을 넘도록 다시
1686
+ 잡았다.
1687
+
1688
+ ### 그물 — `audit:contrast` 가 박힌 색도 잰다
1689
+
1690
+ `CodeBox.css` 의 `.code-token.*` 에 박힌 hex 를 읽어 `--color-code-bg` 위에서
1691
+ 잰다. 규칙 꼴이 바뀌어 색을 못 찾게 되면 그것도 잡는다(넷 미만이면 빨개진다).
1692
+
1693
+ **틴트 겹은 이 문이 못 본다** — 그려 봐야 아는 것이라 브라우저 훑기의 몫이다.
1694
+ 그렇게 적어 두었다.
1695
+
1696
+ ## 0.30.8
1697
+
1698
+ ### 고침 — **필수 표시** 셋과 기기 목록의 날짜
1699
+
1700
+ `*` 하나로 "이 칸은 비울 수 없다" 를 말하는 자리가 셋 있었고, 셋 다 면을
1701
+ 칠하는 `--color-main` 이라 **2.90** 이었다.
1702
+
1703
+ | 자리 | px |
1704
+ |---|---:|
1705
+ | `TextField` `.text-field__required` | 12 |
1706
+ | `Panel` `.required-mark` | 14 |
1707
+ | `PhonemeSelectBlock` `.phoneme-select-block__required` | 17 |
1708
+
1709
+ `Badge`·`Calendar` 와 같은 처방으로 파란 기를 절반 남기고 끌어왔다. **뜻을
1710
+ 지닌 글자**라 색을 지우지 않는 쪽이 맞다.
1711
+
1712
+ `DeviceListPopup` `.dlp-date` (16px, 2.48 → 4.67)도 함께 — `--color-lightGray`
1713
+ 였다.
1714
+
1715
+ 얼린 목록 112 → **108**.
1716
+
1717
+ ### 훑기의 거짓 양성 셋째 갈래 — **`--dark` 갈래**
1718
+
1719
+ `SectionHeader` 의 `.section-header--dark .section-header__eyebrow` 가 2.90 으로
1720
+ 걸렸는데, 그 갈래는 **어두운 지면 위에 놓으라고 만든 것**이고 배경을 스스로
1721
+ 칠하지 않는다(투명). 놀이터에서는 밝은 지면에 얹혀 있어 그렇게 나온다. 제
1722
+ 지면(#1e2227)에서는 5.41 이다.
1723
+
1724
+ **고치지 않았다** — 지면은 소비자가 대는 것이고, 그건 계약이지 결함이 아니다.
1725
+ 지금까지 나온 거짓 양성은 셋이다: 형제가 그리는 배경, 그러데이션·이미지 배경,
1726
+ 그리고 이 `--dark` 갈래.
1727
+
1728
+ ## 0.30.7
1729
+
1730
+ ### 고침 — `Calendar` 일곱 자리 (오늘까지 나온 것 중 가장 큰 무리)
1731
+
1732
+ | 자리 | 무엇인가 | 전 |
1733
+ |---|---|---:|
1734
+ | `.calendar-visit-badge__no-visit` | "방문 없음" 칩의 흰 글자 | **1.71** |
1735
+ | `.calendar-visit-badge` (기본) | 틴트 위 같은 색 글자 | 2.63 |
1736
+ | `.calendar-visit-badge--scheduled` | 〃 | 1.78 |
1737
+ | `.calendar-visit-badge--no-visit` | 〃 | 2.63 |
1738
+ | `.calendar-day-header` | 요일 이름 | 2.48 |
1739
+ | 〃 `:first-child` / `:last-child` | **일요일 빨강 · 토요일 파랑** | 3.89 / 2.90 |
1740
+ | `.calendar-cell__number` 주말 갈래 | 날짜 숫자의 주말 색 | 3.89 / 2.90 |
1741
+
1742
+ **주말 색은 지우지 않았다.** 일요일 빨강·토요일 파랑은 **색이 곧 뜻**인 자리라,
1743
+ `Badge` 와 같은 처방으로 `--color-text-primary` 쪽에 절반 섞어 **읽히게만** 했다.
1744
+ 색조는 절반 남는다.
1745
+
1746
+ `"방문 없음"` 칩은 흰 글자를 연회색 위에 얹어 1.71 이었다. **흐리게 보이라고
1747
+ 만든 것이지 안 보이라고 만든 것은 아니다** — 흐린 인상은 틴트 배경이 이미
1748
+ 내고 있으므로 글자만 끌어올렸다.
1749
+
1750
+ `.calendar-cell.today` 의 흰 숫자는 그대로다 — 브랜드 면 위 흰 글씨다.
1751
+
1752
+ 얼린 목록 119 → **112**.
1753
+
1754
+ ## 0.30.6
1755
+
1756
+ ### 고침 — 고른 개수와 파일 고르기 단추 (둘 다 2.96 → 7.07)
1757
+
1758
+ - `BulkBar` `.bulk-bar__count b` (16px) — **고른 개수**다. 굵게 세운 것은
1759
+ 읽으라는 뜻인데 면을 칠하는 `--color-main` 이었다.
1760
+ - `FileUpload` `.file-upload__browse-button` (14px) — 흰 바탕(`--color-pureWhite`)
1761
+ 위 글자인데 `--color-main` 이었다. 테두리는 그림이라 그대로 둔다.
1762
+
1763
+ 얼린 목록 121 → **119**.
1764
+
1765
+ 라우트 열둘을 더 훑었고(누적 쉰셋), 나머지에서 나온 것은 전부
1766
+ **브랜드 면 위 흰 글씨**(2.96)였다 — 그 결정에 속한다.
1767
+
1768
+ ## 0.30.5
1769
+
1770
+ ### 고침 — `StatChart` 의 **증감 표시** (1.91·3.89 → 통과)
1771
+
1772
+ `.manager-stat-card__change--up` 은 `--color-sub`(12px 에서 **1.91**),
1773
+ `--down` 은 `--color-error`(3.89)였다. **이 카드의 요점이 그 숫자**인데
1774
+ 가장 안 읽히는 자리였다.
1775
+
1776
+ `Badge` 의 soft·outline 과 같은 처방을 썼다 — `--color-text-primary` 쪽으로
1777
+ 절반 섞는다. 실측 어두운 지면에서 up **10.78**, down **7.66**, 밝은 지면에서도
1778
+ 둘 다 통과. 오름은 청록, 내림은 빨강이라는 뜻이 절반 남는다.
1779
+
1780
+ 얼린 목록 123 → **121**.
1781
+
1782
+ ### 알게 된 것 — `--color-error` 19곳도 이 처방으로 풀린다
1783
+
1784
+ 앞 판에서 "`--color-error` 는 짙은 짝이 없어 새 색을 정해야 한다" 고 적었는데,
1785
+ 이 판의 `--down` 이 바로 그 경우였고 **새 색 없이 풀렸다.** `color-mix` 로
1786
+ `--color-text-primary` 쪽으로 끌면 테마를 따라 뒤집히면서 붉은 기가 절반 남는다.
1787
+
1788
+ 곧 **나머지 18곳도 같은 방법으로 고칠 수 있다.** 다만 오류 문구는 색이 곧
1789
+ 뜻인 자리라 한꺼번에 바꾸기 전에 사람이 볼 몫으로 남긴다.
1790
+
1791
+ ## 0.30.4
1792
+
1793
+ ### 고침 — 강조 구절과 착용 날짜
1794
+
1795
+ - `SearchSummary` `.search-summary__em` (16px) — **강조하려고 있는 글**인데
1796
+ 면을 칠하는 `--color-main` 이라 2.90 이었다. 강조가 되레 덜 읽혔다. → 7.07
1797
+ - `HearingDeviceInfo` `.hearing-device-info__device-date` (11px) —
1798
+ `--color-lightGray` 2.42 → 4.67
1799
+
1800
+ 얼린 목록 126 → **123**.
1801
+
1802
+ ### 되풀이해 나오는 것 — `--color-error` 를 글자에 쓴 자리 19곳
1803
+
1804
+ `TextField` 의 오류 도움말, `SearchSummary` 의 오류 문구… 훑을 때마다 3.89 로
1805
+ 걸린다. `--color-error`(#d9534f)는 흰 지면에서 3.89 라 **큰 글씨(3.0)에는 되고
1806
+ 본문(4.5)에는 안 된다.** 얼린 목록에 19곳 있다.
1807
+
1808
+ `--color-main`·`--color-sub` 와 달리 **짙은 짝이 없다.** `--color-text-main`
1809
+ 처럼 `--color-text-error` 를 만들려면 새 브랜드 색을 정해야 하므로 손대지
1810
+ 않았다. 소비자 한 곳은 자기 앱에서 `#b91c1c` 로 옮겼다.
1811
+
1812
+ ## 0.30.3
1813
+
1814
+ ### 고침 — 자리표시와 고리 이름표 (2.42·2.48 → 4.67)
1815
+
1816
+ 훑기를 이어 라우트 여섯을 더 봤다.
1817
+
1818
+ - `DatePicker` `.date-picker__placeholder` (16px) — `Dropdown` 의 같은 자리를
1819
+ 0.25.0 에서 고치면서 이쪽을 놓쳤다. 이제 짝이 맞는다.
1820
+ - `RingChart` `.ring-chart__label` (11·12px) — 고리가 무엇인지 알려 주는 이름표다.
1821
+
1822
+ 둘 다 `--color-lightGray`(테두리·구분선용)였고 `--color-text-secondary` 로 옮겼다.
1823
+ 얼린 목록 128 → **126**.
1824
+
1825
+ ### 알아 둘 것 — `ProgressIndicator` 의 **진행 중인 단계**가 1.94 다
1826
+
1827
+ `.progress-indicator-dot.active` 는 `--color-sub` 면 위에 흰 글씨다. **1.94** —
1828
+ 코어에서 지금까지 잰 것 중 가장 낮다. 끝난 단계(`.done`, `--color-main` 위)는
1829
+ 2.96 이다.
1830
+
1831
+ **고치지 않았다.** 브랜드색 면 위 흰 글씨라 여태 이야기해 온 그 결정에 속하고,
1832
+ 면 색을 바꾸는 일이라 혼자 정할 몫이 아니다. 다만 이 자리는 **지금 어느 단계에
1833
+ 있나**를 알려 주는 곳이라, 그 결정을 할 때 가장 먼저 볼 자리로 적어 둔다.
1834
+
1835
+ ### 훑기의 거짓 양성 하나 더
1836
+
1837
+ `.progress-bar__label` 이 1.03 으로 나왔는데, 그건 **채워진 구간 위에 얹히는**
1838
+ 갈래(`--progress-bar-label-color-on-fill`)다. 그 채움을 형제 요소가 그려서
1839
+ `backgroundColor` 로는 안 보인다 — `promo-banner` 와 같은 꼴이다.
1840
+
1841
+ ## 0.30.2
1842
+
1843
+ ### 고침 — 훑기를 이어 다섯 곳 더 (탭 이름·슬라이더 값 포함)
1844
+
1845
+ 라우트 열둘을 더 훑었다. 전부 **면을 칠하는 토큰을 글자에** 쓴 자리다.
1846
+
1847
+ | 자리 | 무엇인가 | px | 전 → 후 |
1848
+ |---|---|---:|---|
1849
+ | `Tabs` `.tabs__tab--active` | **고른 탭의 이름** | 16 | 2.90 → **7.07** |
1850
+ | `Slider` `.slider-wrap__value` | **지금 값** | 14 | 2.90 → 7.07 |
1851
+ | `Slider` `.slider-wrap__minmax` | 최소·최대 눈금 | 12 | 2.48 → 4.67 |
1852
+ | `Toolbar` `.toolbar__count-highlight` | 강조 숫자 | 18 | 2.90 → 7.07 |
1853
+ | `Button` `.button__count` | 개수 | 14 | 2.63 → 7.07 |
1854
+
1855
+ 앞의 넷은 `--color-main`, 눈금 하나는 `--color-lightGray` 였다.
1856
+ `--color-text-main` / `--color-text-secondary` 로 옮겼다 — 둘 다 테마를 따라간다.
1857
+
1858
+ **그림은 안 건드렸다.** 탭의 밑줄은 `--color-main` 그대로다. 채운 단추 위
1859
+ 흰 `button__count` 갈래도 그대로다 — 브랜드 면 위 흰 글씨는 여태 이야기해 온
1860
+ 그 결정에 속한다.
1861
+
1862
+ `.tabs__tab--active` 가 이 판에서 가장 넓게 닿는다 — 탭을 쓰는 화면마다
1863
+ **고른 탭의 이름**이 짙어진다.
1864
+
1865
+ 얼린 목록 133 → **128**.
1866
+
1867
+ ## 0.30.1
1868
+
1869
+ ### 고침 — 안내문·도움말 넷이 테두리색으로 쓰여 있었다 (2.48 → 4.67)
1870
+
1871
+ 브라우저로 코어 컴포넌트를 훑어 찾았다. 넷 다 `--color-lightGray`(테두리·구분선용)
1872
+ 를 **읽으라고 있는 글**에 쓰고 있었다:
1873
+
1874
+ - `Table` `.table-row--empty` — "결과가 없습니다" (14px)
1875
+ - `Panel` `.panel-row--empty` — "아직 기록이 없습니다" (14px)
1876
+ - `Panel` `.panel-row__helper` — 입력 도움말 (12px)
1877
+ - `TextField` `.text-field__helper` — 입력 도움말 (12px)
1878
+
1879
+ `--color-text-secondary` 로 옮겼다 — 밝은 지면 4.67, 어두운 지면 5.26.
1880
+ **테마를 따라가는 토큰**이라 두 쪽이 함께 맞는다.
1881
+
1882
+ `audit:surface-as-text` 의 얼린 목록이 137 → **133** 으로 줄었다. 줄어드는 것도
1883
+ 잡게 해 둔 덕에, 고치고 목록을 안 고치면 빨개진다 — 실제로 빨개져서 다시 얼렸다.
1884
+
1885
+ ## 0.30.0
1886
+
1887
+ ### 고침 — `Badge` 의 `soft`·`outline` 글자가 안 읽혔다 (겉모습이 바뀐다)
1888
+
1889
+ 소리방향성이 클래스를 적어 보고해 주어 찾았고, 재 보니 **한 갈래가 아니라
1890
+ 전부**였다. `soft` 와 `outline` 은 변형 색(`--badge-color`)을 글자에 그대로
1891
+ 쓰는데, 그 색들은 **면을 칠하라고 있는 것**이다. 14px 기준 4.5 가 필요한데:
1892
+
1893
+ | 갈래 | 전 (soft·outline 공통) | 후 soft | 후 outline |
1894
+ |---|---:|---:|---:|
1895
+ | sub | **1.91** | 5.36 | 5.72 |
1896
+ | default | 2.48 | 6.22 | 6.73 |
1897
+ | main | 2.90 | 6.81 | 7.50 |
1898
+ | like | 3.18 | 7.23 | 8.06 |
1899
+ | danger | 3.89 | 8.08 | 9.13 |
1900
+ | disabled | 1.67 | 4.96 | 5.19 |
1901
+
1902
+ 어두운 테마도 함께 쟀다 — soft 5.53~8.86, outline 5.80~10.78.
1903
+
1904
+ **고친 방법은 새 토큰을 안 만든다.** `--color-text-primary` 쪽으로 절반 섞어
1905
+ 끌어온다:
1906
+
1907
+ ```css
1908
+ --badge-text-color: color-mix(in srgb, var(--badge-color) 50%, var(--color-text-primary));
1909
+ ```
1910
+
1911
+ 그 토큰은 **테마를 따라 뒤집히므로**(밝은 곳에선 검정 쪽, 어두운 곳에선 흰 쪽)
1912
+ **한 줄이 두 테마를 모두 맞춘다.** 0.27.0 에서 간접 토큰의 얼어붙음을 푼 것이
1913
+ 여기서 값을 했다 — 그전이었다면 다크에서 이 방법이 안 통했다.
1914
+
1915
+ **색조는 절반 남는다.** 브라우저에서 눈으로도 확인했다 — 파랑·청록·빨강이
1916
+ 그대로 구분된다.
1917
+
1918
+ - **테두리는 안 바꿨다.** 그림이라 3.0 기준이고, 글자와 달리 변형 색을 그대로 쓴다.
1919
+ - **`filled` 은 그대로다** — 흰 글씨 on 브랜드 면(1.94~3.96)은 여태 이야기해 온
1920
+ 브랜드 정의 결정에 속한다.
1921
+ - 갈아 끼우려면 `--badge-text-color` 를 덮는다. **면만 바꾸면 글자는 안 따라온다**
1922
+ — `ActionCard` 의 `--ac-color-text` 와 같은 뜻으로 일부러 갈라 두었다.
1923
+
1924
+ ## 0.29.1
1925
+
1926
+ ### 고침 — `SummaryStrip` 의 **값**이 다크에서 안 보였다 (실측 1.02)
1927
+
1928
+ `.summary-strip__value` 에 `color` 가 **없었다.** 그래서 바깥에서 물려받았는데,
1929
+ 그 색은 앱이 **밝은 지면을 보고 고른 것**이다. 컴포넌트는 제 지면
1930
+ (`--color-surface`)을 칠하고 그것은 다크에서 뒤집히므로, **지면만 뒤집히고 글자는
1931
+ 안 따라왔다** — 놀이터 실측 `rgb(4,34,51)` on `rgb(30,34,39)` = **1.02**.
1932
+
1933
+ `--color-text-primary` 로 명시했다. 실측 밝은 18.52 · 어두운 **14.26**.
1934
+
1935
+ 이름표(`__label`)는 원래 `--color-gray` 라 테마를 따라가고 있었다(4.67 / 5.26).
1936
+ 그래서 **한 컴포넌트 안에서 이름표는 멀쩡하고 값만 안 보이는** 꼴이었다.
1937
+
1938
+ **규칙 하나로 적어 둔다 — 지면을 칠하는 컴포넌트는 글자색도 스스로 정해야 한다.**
1939
+ 안 정하면 밖에서 물려받고, 그 색은 다른 지면을 보고 고른 것이다.
1940
+
1941
+ 소리방향성이 이 자리를 "값이 2.64" 로 보고해 주어 찾았다. 원인은 그쪽이 짚은
1942
+ `--color-text-muted` 가 아니라 **색이 아예 없던 것**이었고, 그래서 그쪽 앱의
1943
+ 글자색이 그대로 들어와 있었다.
1944
+
1945
+ ## 0.29.0
1946
+
1947
+ ### 고침 — `.force-light-scheme` 도 **스코프 원소 자신·위**에서 걸린다
1948
+
1949
+ 0.28.0 에서 `[data-theme=…]` 에 한 것과 같은 몫이다. 프리픽스가 후손 형태만
1950
+ 만들어서, "이 아래는 라이트로 고정" 이라고 선언하기 가장 자연스러운 두 자리
1951
+ (스코프 원소 자신, 그 위)가 조용히 안 걸렸다.
1952
+
1953
+ ### 문서 — `.force-light-scheme` 을 드디어 적었다
1954
+
1955
+ 이 클래스는 **어느 문서에도 없었다.** `src/tokens.css` 안에만 있었고, 소비자
1956
+ 둘이 각각 소스를 읽고 찾아 썼다(한쪽은 PDF 결과지에, 한쪽은 로그인·약관 팝업에).
1957
+
1958
+ 적으면서 실패담도 함께 적었다 — 말귀는 랜딩에서 코어 토큰 **열여섯을 손으로**
1959
+ 되돌리고 있었는데, 0.27.0 이 역할 토큰 둘을 새로 내자 그 둘만 안 따라와
1960
+ **머리줄 링크 hover 가 6.89 → 1.93** 이 됐다. 지면은 손으로 밝게 고정했는데
1961
+ 글자만 다크 값이 온 것이다. **목록을 손으로 따라다니는 길은 반드시 썩는다.**
1962
+
1963
+ ### 그물
1964
+
1965
+ `audit:theme-tokens` 의 조상 검사가 `.force-light-scheme` 도 본다.
1966
+
1967
+ ## 0.28.0
1968
+
1969
+ ### 고침 — `styles.css` 를 쓰면 **`initTheme()` 이 아무 일도 안 했다**
1970
+
1971
+ 코어는 `initTheme()`/`applyTheme()` 을 함께 내주고, 그것들은 `<html data-theme="dark">`
1972
+ 를 단다. 그런데 스코프 CSS 의 프리픽스가 그 규칙을 **후손 선택자**로 바꿔 놓았다:
1973
+
1974
+ ```
1975
+ .ihabdevteam-core-scope [data-theme=dark] { … }
1976
+ ```
1977
+
1978
+ `<html>` 은 스코프 클래스의 **조상**이다. 그래서 **한 번도 안 맞았다.**
1979
+ `tokens.css` 만 쓰는 소비자는 프리픽스가 없어 멀쩡했고, 그래서 이 결함이
1980
+ 오래 안 보였다.
1981
+
1982
+ **얼마나 오래됐나 — 적어도 0.23.1 까지 거슬러 간다.** 배포된 tarball 넷
1983
+ (0.23.1 · 0.24.0 · 0.27.0 · 0.28.0)을 받아 확인했다. 앞의 셋은 후손 선택자
1984
+ 하나뿐이고 0.28.0 에서 셋으로 늘었다. 그래서 **0.24.0~0.27.0 의 다크 회귀는
1985
+ 어느 소비자에게도 안 닿았다** — 닿을 길이 없었다(0.27.0 절의 덧붙임 참고).
1986
+
1987
+ 이제 세 자리를 다 받는다 — 조상 · 스코프 원소 자신 · 후손. 실측:
1988
+
1989
+ | `<html>` 의 속성 | surface | `--color-text-main` |
1990
+ |---|---|---|
1991
+ | 없음 (OS 다크) | `#fbfdff` | `#1e5a8f` |
1992
+ | `data-theme="dark"` | **`#1e2227`** | **`#8bbce4`** |
1993
+ | `data-theme="auto"` (OS 다크) | **`#1e2227`** | **`#8bbce4`** |
1994
+ | `data-theme="light"` | `#fbfdff` | `#1e5a8f` |
1995
+
1996
+ **OS 자동 다크(`prefers-color-scheme`)는 일부러 그대로 두었다.** 그 규칙도 같은
1997
+ 후손 문제로 지금까지 한 번도 안 맞았는데, 함께 고치면 **OS 를 다크로 둔 모든
1998
+ 사용자의 화면이 한꺼번에 뒤집힌다.** 그건 고침이 아니라 제품 결정이라 손대지
1999
+ 않았다. 소비자가 스스로 고른 `data-theme="auto"` 는 위 표대로 걸린다.
2000
+
2001
+ ### 문서 — `--color-text-muted` 는 이름과 달리 본문에 못 쓴다
2002
+
2003
+ 밝은 지면 2.48 · 어두운 지면 2.89 — **양쪽 다 미달**이다. 이름이 `text-` 로
2004
+ 시작한다고 쓰임이 보장되지 않는다. 흐린 보조 글자는
2005
+ `--color-text-secondary`(4.67 / 5.26)를 쓴다. `audit:contrast` 가 이제 이 토큰도
2006
+ 잰다(15개 × 지면 2).
2007
+
2008
+ ### 문서 — 다크는 **다크로 로드해서** 재라
2009
+
2010
+ `data-theme` 을 런타임에 토글하면 이미 그려진 배경이 무효화되지 않아, 변수는
2011
+ 다크인데 배경은 라이트로 읽힌다. 없는 결함이 보이고 있는 결함이 가려진다.
2012
+ CONSUMING.md 2-0-2 에 적었다.
2013
+
2014
+ ## 0.27.0
2015
+
2016
+ ### 고침 — **어두운 테마가 반만 돌고 있었다.** 그리고 0.24.0 이 그 절반을 깨뜨렸다
2017
+
2018
+ 소리방향성이 자기 저장소에서 밟은 함정("지면만 어두운 화면에서 글자를 짙게 했더니
2019
+ 2.56 → 1.05")을 알려 준 덕에 코어를 재 보고 찾았다.
2020
+
2021
+ **1) 0.24.0~0.26.0 이 어두운 테마를 나쁘게 만들었다.** 브랜드색 글자를
2022
+ `--color-darkMain` 으로 옮겼는데, 그 값은 **테마를 안 따라간다.** 어두운 지면에서:
2023
+
2024
+ | 자리 | 0.23.1 | 0.24.0~0.26.0 | 0.27.0 |
2025
+ |---|---:|---:|---:|
2026
+ | `ActionCard` 행선지 | 5.41 | **2.22** | **7.93** |
2027
+ | `ActionCard` 행선지 `--sub` | 8.23 | **3.29** | **10.30** |
2028
+ | `Table` 개수 · `LandingHeader` hover | 5.41 | **2.22** | **7.93** |
2029
+
2030
+ 밝은 지면 값(7.07 / 4.76)은 그대로다.
2031
+
2032
+ > **덧붙임(2026-09-08, 0.28.0 이후 확인) — 이 회귀는 어느 소비자에게도 안 닿았다.**
2033
+ > 0.23.1~0.27.0 의 스코프 CSS 는 다크 규칙을 `.ihabdevteam-core-scope [data-theme=dark]`
2034
+ > **후손 선택자 하나로만** 내보냈다(네 판의 배포 tarball 을 받아 확인). 소비자
2035
+ > 셋 중 둘은 `styles.css` 를 쓰면서 `data-theme` 을 `<html>`(스코프의 **조상**)이나
2036
+ > 스코프 원소 **자신**에 다는 구성이라 **그 규칙이 애초에 안 맞았다.** 나머지
2037
+ > 하나는 `tokens.css` 를 쓰지만 0.13.0 에 묶여 있어 이 판들을 안 받았다.
2038
+ > 곧 **위 표의 2.22 는 대기하고 있던 값이지 사용자가 본 값이 아니다.** 말귀가
2039
+ > 자기 번들을 뜯어 이 사실을 짚어 주었고, 나는 배포된 tarball 로 다시 확인했다.
2040
+ > 그렇다면 0.28.0 이 고친 것은 이 회귀보다 **훨씬 오래되고 큰 것**이다 —
2041
+ > 스코프 CSS 를 쓰는 소비자에게 **다크 테마 자체가 통째로 죽어 있었다.**
2042
+
2043
+ 역할 토큰 **`--color-text-main` · `--color-text-sub`** 를 새로 두고 그것을 쓴다.
2044
+ 밝은 지면에서는 짙은 쪽, 어두운 지면에서는 연한 쪽으로 **알아서 뒤집힌다.**
2045
+ `--color-darkMain` 은 면을 칠할 때 쓰는 색으로 남는다(짙은 브랜드 배너).
2046
+
2047
+ **2) 그러다 더 큰 것이 나왔다 — 간접 토큰 여덟이 어두운 테마에서 얼어 있었다.**
2048
+
2049
+ `--color-surface: var(--color-white)` 처럼 다른 토큰을 가리키기만 하는 값은
2050
+ **선언한 원소에서 확정되어** 상속된다. 어두운 블록에서 원본만 바꾸면 이것들은
2051
+ 밝은 값에 얼어붙는다. 얼어 있던 여덟:
2052
+
2053
+ `--color-surface` · `--color-surface-raised` · `--color-surface-sunken` ·
2054
+ `--color-border` · `--color-text-primary` · `--color-text-secondary` ·
2055
+ `--color-text-muted` · `--color-text-on-brand`
2056
+
2057
+ **그전에는 양쪽 다 안 뒤집혀서 어긋난 것이 안 보였다.** 글자를 뒤집고 나서야
2058
+ 지면이 안 따라오는 것이 드러났다. 셋(수동 dark · OS 자동 · auto) 모두에서
2059
+ 다시 선언했고, `.force-light-scheme` 에도 짝을 맞췄다.
2060
+
2061
+ **겉모습이 바뀐다 — `data-theme="dark"` 를 쓰는 자리에서만.** 그전에는 그런
2062
+ 자리에서 **지면이 흰 채로 남았다.** 이제 실제로 어두워진다. 밝은 테마와,
2063
+ `data-theme` 을 안 쓰는 자리는 하나도 안 바뀐다(브라우저에서 셋 다 확인했다 —
2064
+ 강제 밝음 · 강제 어둠 · OS 어두움+속성 없음).
2065
+
2066
+ ### 그물 — `audit:theme-tokens`
2067
+
2068
+ `:root` 의 간접 토큰이 가리키는 원본을 어두운 블록이 바꾸면, 그 간접 토큰도
2069
+ 거기서 다시 선언해야 한다. 어두운 블록이 셋이라 셋 다 본다 — **하나만 고치고
2070
+ 나머지를 잊는 것**이 이 파일에서 가장 하기 쉬운 실수다.
2071
+
2072
+ `audit:contrast` 도 이제 **두 지면 모두**에서 잰다. 밝은 쪽만 보던 문이 위
2073
+ 1번을 초록으로 통과시켰다.
2074
+
2075
+ ## 0.26.0
2076
+
2077
+ ### 더해짐 — `CORE_VERSION`, 판올림이 실제로 닿았는지 보는 표식
2078
+
2079
+ 소리방향성이 낸 생각이다. **판 번호로는 확인이 안 된다** — `package.json` 은
2080
+ 디스크에서 바로 오므로, Vite dev 서버가 판올림을 못 알아챈 채 옛 사본을 내주는
2081
+ 동안에도 새 번호가 나온다(2-0-1). 그래서 "올렸다고 믿으며 옛 코드를 검증하는"
2082
+ 일이 생긴다.
2083
+
2084
+ `CORE_VERSION` 은 소비자가 실제로 싣는 알맹이 안에 있어 **같은 길을 지나온다:**
2085
+
2086
+ ```js
2087
+ import('@ihabdevteam/core/components').then(m => console.log(m.CORE_VERSION))
2088
+ ```
2089
+
2090
+ - `index`·`components`·`hooks`·`utils` **네 갈래 모두**에서 내보낸다. 갈래마다
2091
+ 따로 미리묶이므로 자기가 쓰는 갈래에서 봐야 그 갈래의 낡음을 안다.
2092
+ - `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다. 이 상수가 낡으면
2093
+ 처음보다 나쁘다 — 소비자는 이걸 보고 새 판이라 믿는다.
2094
+
2095
+ 겉모습은 하나도 안 바뀐다.
2096
+
2097
+ ## 0.25.0
2098
+
2099
+ ### 고침 — 면·테두리용 토큰을 글자에 쓰던 자리 셋 더 (겉모습이 바뀐다)
2100
+
2101
+ 소리방향성이 전 화면을 훑어 **면과 글자를 갈라** 넘겨 준 표에서 골랐다. 셋 다
2102
+ 소스에서 확인하고 브라우저에서 전후를 쟀다.
2103
+
2104
+ | 자리 | 무엇이 문제였나 | 전 | 후 |
2105
+ |---|---|---:|---:|
2106
+ | `Table` `.table-header__count` | 개수는 **글자**인데 면을 칠하는 `--color-main` (14px) | 2.90 | **7.07** |
2107
+ | `Dropdown` 자리표시 라벨 | 테두리색 `--color-lightGray` (16px) | 2.42 | **4.55** |
2108
+ | `Button` `variant="transparent-gray"` 라벨 | 값이 아니라 **알파** — `--color-gray-a80` 이 `#828fa2` 로 합성 (13.3px) | 3.20 | **4.67** |
2109
+
2110
+ - 개수 바로 위의 `.table-header__icon` 은 **그림**이라 `--color-main` 그대로 둔다.
2111
+ - 자리표시는 `--color-gray` 로 갔다. 값이 든 라벨(`--color-darkGray`)과 구분이
2112
+ 남는다. 다만 지면이 `--color-deepWhite`(#f8fafc)라 **4.55 로 여유가 얇다** —
2113
+ 이보다 어두운 지면에 얹으면 미달로 떨어진다.
2114
+ - transparent-gray 는 **같은 파일의 lightGray 변형이 이미 받은 처리**를 그대로
2115
+ 적용한 것이다. 그 변형 위에는 "전경만 gray 로 올리고 배경·테두리의 연한 인상은
2116
+ 그대로 둔다" 는 주석이 예전부터 붙어 있었다. gray 변형만 빠져 있었다.
2117
+ **테두리는 그대로 `--color-gray-a80`** 이다.
2118
+
2119
+ ### 그대로 두는 것
2120
+
2121
+ - **브랜드색 면 위 흰 글씨**(`variant="main"` 라벨 2.96, 선택된 SegmentedControl
2122
+ 라벨) — 면을 한 단계 짙게(`--color-deepMain`) 해도 3.45 라 4.5 에 못 미친다.
2123
+ 브랜드 색 정의에 닿는 이야기라 손대지 않는다.
2124
+ - `--color-lightGray` 를 글자색으로 쓰는 나머지 **48곳**.
2125
+
2126
+ ### 이미 되어 있던 것
2127
+
2128
+ - `LandingHeader` 활성 링크(`a[aria-current='page']`)는 **0.24.0 에서 이미 고쳤다.**
2129
+ 그 규칙은 hover 와 한 덩어리라 hover 기본값을 옮길 때 함께 옮겨졌다.
2130
+
2131
+ ## 0.24.0
2132
+
2133
+ ### 고침 — 브랜드색으로 쓰던 **글자**를 읽히게 (겉모습이 바뀐다)
2134
+
2135
+ 소리방향성이 자기 화면을 재어 넘겨 준 값에서 시작했다. 재 보니 문제 자리는 그쪽
2136
+ 앱이 아니라 **코어 CSS** 였다.
2137
+
2138
+ **`ActionCard` — 한 변수가 면 셋과 글자 하나를 겸하고 있었다.** `--ac-color` 는
2139
+ 테두리·칩 배경·칩 아이콘·바닥 행선지 글씨 네 곳에 쓰이는데, 앞의 셋은 면이라
2140
+ 브랜드색이 맞고 **마지막 하나만 기준이 다르다**(16px/600 → 4.5 필요).
2141
+
2142
+ - `--ac-color-text` 를 새로 두고 바닥 행선지 글씨만 그것을 보게 했다.
2143
+ 기본값은 `--color-darkMain`, `--action-card--sub` 는 `--color-darkSub`.
2144
+ - `.action-card__desc` 는 테두리색(`--color-lightGray`)을 쓰고 있었다 →
2145
+ 글자용 회색(`--color-gray`)으로.
2146
+ - **면은 하나도 안 바뀐다.** 테두리·칩 배경·칩 아이콘은 그대로 브랜드색이다.
2147
+
2148
+ 브라우저 실측(흰 카드 #fbfdff, 16px):
2149
+
2150
+ | 자리 | 전 | 후 |
2151
+ |---|---:|---:|
2152
+ | 바닥 행선지 (기본) | 2.90 | **7.07** |
2153
+ | 바닥 행선지 (`--sub`) | **1.91** | **4.76** |
2154
+ | 설명문 | 2.48 | **4.67** |
2155
+
2156
+ **`LandingHeader` — 링크 hover 색 기본값**을 `--color-main`(2.83)에서
2157
+ `--color-darkMain`(6.89)으로. 기본 링크색(`--color-deepGray` 6.04)은 원래 괜찮았고,
2158
+ hover 만 미달이었다. `--landing-header-link-hover` 를 이미 덮고 있는 쪽은 그대로다.
2159
+
2160
+ **겉모습이 바뀐다** — 세 제품의 ActionCard 행선지 글씨와 설명문이 짙어진다.
2161
+ 되돌리려면 그쪽에서 한 줄 덮으면 된다:
2162
+
2163
+ ```css
2164
+ .action-card { --ac-color-text: var(--ac-color); }
2165
+ ```
2166
+
2167
+ **카드 색을 갈아 끼우던 쪽은 두 줄이 된다.** `--ac-color` 만 바꾸면 이제 글씨는
2168
+ 안 따라온다. 일부러 그렇게 두었다 — 면 색을 낮추려다 글자까지 안 읽히게 되는 것이
2169
+ 원래 문제였다. CONSUMING.md 3-6 에 보기를 적었다.
2170
+
2171
+ **아직 안 고친 것** — `--color-lightGray` 를 글자색으로 쓰는 자리가 코어에 48곳
2172
+ 더 있다(아이콘·비활성 제외). 세 제품의 화면이 함께 바뀌는 일이라 사람이 정할
2173
+ 몫으로 남겨 두었다. 브랜드색 면 위 흰 글씨(2.90/1.91)도 마찬가지다 — 면을 한 단계
2174
+ 짙게 해도 4.5 에 못 미쳐 브랜드 정의에 닿는 이야기가 된다.
2175
+
2176
+ ## 0.23.1
2177
+
2178
+ ### 되돌림 — `dist/packages/core/` 의 `{"type":"module"}` 쪽지 (0.23.0 이 깨뜨린 것)
2179
+
2180
+ **0.23.0 은 소비자를 깨뜨렸다.** 말귀(speechear-v2)에서 vitest 가 **426개 중 153개
2181
+ 파일**에서 무너졌다(시험 6308 → 3256). 빌드는 멀쩡했다 — 묶개는 제 해석기로 읽으니까.
2182
+ 무너진 것은 **Node ESM 으로 싣는 자리**뿐이었고, vitest 가 node_modules 를 그렇게 싣는다.
2183
+
2184
+ ```
2185
+ Cannot find module .../dist/packages/core/src/utils/speak
2186
+ imported from .../dist/packages/core/src/utils.js
2187
+ ```
2188
+
2189
+ 까닭은 이 나무의 상대 import 에 **확장자가 없다**는 것이다. vitest 는 의존을
2190
+ "Node 로 그냥 실을 수 있나" 로 갈라 싣고, 그 답을 **가장 가까운 package.json 의
2191
+ `type`** 에서 찾는다:
2192
+
2193
+ - `type` 없음 → "Node 로는 못 싣겠다" → **묶개가 제 해석기로 읽는다** → 잘 돈다
2194
+ - `type: module` → "실을 수 있겠다" → **Node 엄격 해석으로 넘긴다** → 전부 죽는다
2195
+
2196
+ **`type` 의 없음이 일을 하고 있었다.** 그것을 채워 넣은 것이 0.23.0 이다.
2197
+ 쪽지가 없애 준 것은 `MODULE_TYPELESS_PACKAGE_JSON` **경고 한 줄**뿐이었다 —
2198
+ 경고 하나를 지우려고 시험 3052개를 잃었다.
2199
+
2200
+ 다섯 줄짜리 vitest 프로젝트로 갈라 재서 원인을 `type` 한 줄로 좁혔다:
2201
+ 0.22.0 통과 · 0.23.0 실패 · 0.23.0 에서 쪽지만 치우면 통과 · `sideEffects` 만
2202
+ 남기면 통과 · `type` 을 도로 놓으면 실패.
2203
+
2204
+ `test/esmTreeIsBundlerOnly.test.ts` 가 이 자리를 잠근다 — **조건부**로. 확장자 없는
2205
+ 상대 import 가 남아 있는 한 `type` 을 달 수 없고, 언젠가 전부 `.js` 를 붙이면
2206
+ (NodeNext) 잠금이 저절로 풀린다.
2207
+
2208
+ ### 그대로 두는 것 — 0.23.0 의 나머지
2209
+
2210
+ - `"module"` 을 내보내는 진입점으로 고친 것: 그대로 둔다. 말귀에서 문제없음을 확인했다.
2211
+ - `audit:exports` 가 exports 맵이 가리키는 **그 파일**을 열도록 고친 것: 그대로 둔다.
2212
+ - CONSUMING.md 2-0(ESM 알맹이는 묶개 전용): 그대로. vitest 이야기를 보탰다.
2213
+
2214
+ ### 문서 — 판을 올린 뒤 화면이 비면 Vite 캐시부터 지운다
2215
+
2216
+ - 코어를 올린 직후 `504 (Outdated Optimize Dep)` 로 동적 import 가 실패해 화면이
2217
+ 비는 일이 있다. 코어 결함이 아니라 Vite 미리묶기 캐시가 낡은 것이다.
2218
+ `rm -rf node_modules/.vite` 로 풀린다. 소리방향성과 이 저장소 놀이터에서 각각 났다.
2219
+ CONSUMING.md 2-0-1 에 적었다.
2220
+
2221
+ ## 0.23.0
2222
+
2223
+ ### 고침 — 내보내는 ESM 나무가 자기가 ESM 이라고 밝힌다
2224
+
2225
+ - `dist/packages/core/` 에 `{"type":"module"}` 쪽지를 놓는다. 그전에는 그 안의
2226
+ `.js` 들에 가장 가까운 package.json 이 꾸러미 뿌리였고 거기엔 `"type"` 이 없어,
2227
+ **Node 가 CJS 로 읽다 실패한 뒤 ESM 으로 다시 읽었다:**
2228
+
2229
+ ```
2230
+ [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of …/index.js is not specified
2231
+ and it doesn't parse as CommonJS. Reparsing as ES module …
2232
+ ```
2233
+
2234
+ 이 패키지를 Node 로 읽는 모든 자리에서 나던 잔소리다. 뿌리에 `"type"` 을 다는
2235
+ 길은 같은 dist 의 `*.cjs.js` 를 뒤집어 깨뜨리므로, 이 나무에만 놓았다.
2236
+ webpack 이 `sideEffects` 도 가장 가까운 package.json 에서 찾으므로 뿌리와 같은
2237
+ 뜻(`**/*.css`)을 함께 적었다.
2238
+
2239
+ ### 고침 — `module` 이 **안 내보내는 파일**을 가리키고 있었다
2240
+
2241
+ - `"module": "dist/ihabdevteam-core.esm.js"` 였는데, `files` 의 `"!dist/*.esm.js"`
2242
+ 때문에 그 파일은 꾸러미에 안 들어간다. 실제로 소비자 `node_modules` 안에
2243
+ `*.esm.js` 는 **0개**다. exports 를 읽는 요즘 묶개는 이 칸을 안 보므로 아무도
2244
+ 안 다쳤지만, 적힌 값이 거짓이었다. 내보내는 ESM 진입점
2245
+ (`dist/packages/core/src/index.js`)으로 고쳤다.
2246
+
2247
+ ### 문서 — ESM 알맹이가 **묶개 전용**임을 CONSUMING.md 에 적었다
2248
+
2249
+ - `import` 조건이 가리키는 나무는 맨 Node 로 못 읽는다. 첫 줄이
2250
+ `import './styles.css'` 이고, 안쪽 import 가 확장자 없이 적혀 있다. 묶개는 둘 다
2251
+ 알아듣는다(소비자 셋 다 Vite 이고 잘 돈다). 묶개를 안 거치는 Node 자리에서는
2252
+ `require` 조건의 CJS 묶음을 쓴다 — 그건 그대로 돈다.
2253
+
2254
+ ### 검수 — `audit:exports` 가 **딴 파일을 재고 있었다**
2255
+
2256
+ - 이 문은 `import('@ihabdevteam/core')` 가 실패하면 조용히
2257
+ `dist/ihabdevteam-core.esm.js` 로 갈아탔다. 그 실패는 **늘 나고 있었고**(위의 CSS
2258
+ import), 갈아탄 그 파일은 **꾸러미에 안 들어가는 파일**이다. 즉 소비자가 받지도
2259
+ 않는 것의 이름 수를 세어 놓고 통과라고 말해 왔다.
2260
+ - 갈아타기를 없앴다. exports 맵이 가리키는 그 파일만 열고, 못 열면 빨개진다.
2261
+ 열 수 있도록 `.css`·`.svg` 를 빈 모듈로 바꾸고 확장자 없는 상대 경로를 이어
2262
+ 붙이는 **묶개 흉내 갈고리**를 끼웠다.
2263
+ - 볼 subpath 도 손으로 적지 않고 exports 맵에서 뽑는다 — 예전 목록에는 `./types`
2264
+ 가 빠져 있었다.
2265
+ - 이제 잰 파일 이름을 함께 찍는다. 숫자만 있으면 어느 파일 것인지 알 수 없다.
2266
+
2267
+ 없어진 것도, 새로 연 API 도 없다. 소비자 코드는 그대로다.
2268
+
2269
+ ## 0.22.0
2270
+
2271
+ ### 더해짐 — 가운데(피험자 자리)를 단추로 (`onCenterClick`)
2272
+
2273
+ - 가운데는 그림일 뿐이었다 — `<div>` 에 `pointer-events: none` 이라 앱에서 손댈 데가
2274
+ 없었다. 위에 제 버튼을 덧대는 길은 있었지만, 가운데 원의 크기·자리가
2275
+ `buttonSize`·`size` 에서 나오므로 소비자가 그 계산을 베껴 갖게 된다.
2276
+
2277
+ ```jsx
2278
+ <SoundCompass
2279
+ onCenterClick={play} centerLabel="소리 듣기"
2280
+ centerDisabled={playing} centerAttention={waiting} … />
2281
+ ```
2282
+
2283
+ **안 주면 예전과 완전히 같다** — `<div>` · `pointer-events: none`. 주면 진짜
2284
+ `<button>` 이라 Tab 이 닿고 Enter·Space 로 눌리고 `:focus-visible` 링이 뜬다
2285
+ (실측: 단추일 때도 색이 그림과 같다 — 배경 `rgb(251,253,255)`, 테두리
2286
+ `rgb(221,227,236)`, 글자 `rgb(150,164,184)`).
2287
+
2288
+ - `centerDisabled` 는 `pointer-events: none` · `aria-disabled` · `tabIndex={-1}` 에
2289
+ 더해 **눈으로도 갈린다**(실측: 아이콘 색이 `#96a4b8` → `#dde3ec`).
2290
+
2291
+ - `centerAttention` 은 이미 있는 울림을 그대로 쓴다 — 스피커 재생 울림과 같은
2292
+ 그림·같은 색이라 화면이 한 벌로 읽힌다. **못 누를 때는 걸리지 않는다**:
2293
+ 누를 수 없는 자리로 눈을 끌면 안 된다.
2294
+
2295
+ - 색은 `--scc-*` 로 연다(평소·호버·못 누를 때). 포커스 링은 스피커 버튼과 같은
2296
+ `--scb-focus-ring` 을 쓴다 — 화면 안에서 초점이 한 가지 모양으로 읽히게.
2297
+
2298
+ ### 고침 — `--scb-focus-ring` 이 버튼 안에 갇혀 있었다
2299
+
2300
+ - 0.20.0 에서 상태 색을 뿌리로 옮길 때 이 하나가 `.sound-compass__speaker-btn` 에
2301
+ 남았다. 버튼 밖(가운데 단추)에서는 읽을 수 없었다 — 뿌리로 옮겼다.
2302
+
2303
+ ## 0.21.0
2304
+
2305
+ ### 더해짐 — `showIcon="answered"`
2306
+
2307
+ - `showIcon` 이 전부-아니면-전무였다. 스피커를 **번호로 부르는 화면**은 `false` 를
2308
+ 쓰는데, 그러면 아이콘 노드가 아예 안 그려져 CSS 로 바꿔 끼울 것도 없다. `true` 로
2309
+ 켜면 고르지 않은 스피커까지 아이콘이 붙고 번호가 함께 작아진다(실측: 36px → 18px).
2310
+
2311
+ ```jsx
2312
+ <SoundCompass showIcon="answered" … /> // 답이 정해진 자리만 아이콘, 나머지는 번호
2313
+ ```
2314
+
2315
+ 그 자리에서는 **번호를 그리지 않는다** — 아이콘과 번호를 같이 넣으면 둘 다 작아진다.
2316
+
2317
+ - **가르는 선이 자의적이지 않다.** `idle` 과 `active` 는 아이콘이 둘 다 같은
2318
+ `Volume2` 라 번호보다 더 말해 주는 것이 없다. `played`·`correct` 의 체크와
2319
+ `wrong` 의 가위표만이 번호가 못 하는 말을 한다 — 아이콘이 자리를 차지할 값을
2320
+ 하는 곳만 바꿔 끼운다.
2321
+
2322
+ - `true`·`false` 는 예전 그대로다(실측: `true` 는 아이콘+8px 번호, `false` 는
2323
+ 16px 번호만, `answered` 는 답이 없을 때 `false` 와 같다).
2324
+
2325
+ ## 0.20.0
2326
+
2327
+ ### 더해짐 — 울림이 퍼지는 거리 (`pulseScale`)
2328
+
2329
+ - keyframe 의 끝이 `scale(2.8)` 로 못박혀 있었다. 얼마나 멀리 퍼져야 알맞은지는
2330
+ **스피커 사이 거리**에 달렸는데, 촘촘한 배치에서는 이웃 버튼 앞까지 밀고
2331
+ 들어온다 — 소비 앱 실측: 버튼 지름 90px 이면 울림 지름이 252px 인데 이웃과의
2332
+ 중심 거리가 261px 이었다.
2333
+
2334
+ ```jsx
2335
+ <SoundCompass pulseScale={1.9} … />
2336
+ ```
2337
+
2338
+ 안 주면 아예 얹지 않는다 — CSS 의 기본 2.8 이 살아 예전 그대로다.
2339
+
2340
+ ### 고침 — 울림 색이 그 순간 버튼 색과 달랐다
2341
+
2342
+ - 울림 배경이 `--color-main` 하나로 고정이라, 고른 직후처럼 버튼이 청록
2343
+ (`--scb-played-bg`)인 자리에서 **파란 울림이 번졌다.**
2344
+
2345
+ 울림에 그 스피커의 상태 클래스를 붙여 색이 버튼을 따라가게 했다. `--scb-*` 를
2346
+ 바꿔 끼운 소비자도 저절로 맞고, 덮고 싶으면 `--sc-pulse-color` 를 주면 된다.
2347
+ 재생 중 울림은 값이 같아 변화가 없다(실측: 울림·버튼 모두 `rgb(73,156,223)`).
2348
+
2349
+ - **`--scb-*` 선언 자리를 버튼에서 뿌리로 옮겼다.** 울림은 버튼의 **형제**라 버튼
2350
+ 안에 선언된 변수를 못 본다 — 색을 상태에 묶자마자 투명하게 나왔다(실측:
2351
+ `rgba(0,0,0,0)`). 버튼과 울림이 함께 보려면 공통 조상에 있어야 한다.
2352
+
2353
+ `--scb-*` 를 바깥에서 덮던 소비자는 그대로다. 다만 `.sound-compass__speaker-btn`
2354
+ **자체에** 덮어쓰던 곳이 있다면 울림에는 안 닿는다 — 그때는 한 겹 위로 올리면 된다.
2355
+
2356
+ ## 0.19.0
2357
+
2358
+ ### 더해짐 — 고른 자리 울림의 시간 (`pulseSelectedDuration`)
2359
+
2360
+ - 고른 상태를 짧게만 잡아 두는 검사에서는 울림이 중간에 끊긴다. 소비 앱 실측:
2361
+ 선택을 350ms 만 잡아 두니 울림(1s)이 **35% 지점(퍼짐 1.6배)에서 통째로 사라져**
2362
+ 가장 잘 보이는 확산 구간이 잘려 나갔다.
2363
+
2364
+ ```jsx
2365
+ <SoundCompass pulseSelected pulseSelectedDuration={320} … />
2366
+ ```
2367
+
2368
+ 안 주면 아예 얹지 않는다 — CSS 의 기본 1s 가 살아 예전 그대로다(실측: 기본
2369
+ `1s`, 320 을 주면 `0.32s`, 되풀이는 1회 그대로).
2370
+
2371
+ - **되풀이하는 울림(`activeSpeakerId`)은 1s 고정으로 둔다.** 그쪽은 이어지는
2372
+ 상태를 알리는 것이라 짧을 까닭이 없다. 소비 앱도 그쪽은 1s 가 맞다고 했다.
2373
+ 손잡이를 둘 다 미리 열지 않는다 — 쓰이지 않는 공개 이름은 나중에 걷어내야 할
2374
+ 빚이 된다. 필요해지면 그때 연다.
2375
+
2376
+ ## 0.18.0
2377
+
2378
+ ### 더해짐 — 고른 스피커에도 울림 (`pulseSelected`)
2379
+
2380
+ - 울림은 원래 **소리가 나는 스피커**(`activeSpeakerId`)와 **피드백의 정답**에만
2381
+ 붙었다. 정답을 알려주지 않는 검사는 그 둘을 쓰지 않으므로 **울림이 한 번도 나지
2382
+ 않았다** — 고른 자리에 손맛이 없었다.
2383
+
2384
+ ```jsx
2385
+ <SoundCompass selectedId={picked} pulseSelected … />
2386
+ ```
2387
+
2388
+ **기본은 꺼짐이다.** 묻지 않고 걸리는 연출은 소비자에게 놀라움이 된다 — 0.15.0 의
2389
+ 자동 배율에서 배운 것이다.
2390
+
2391
+ - **한 번만 번진다.** 소리가 나는 동안 계속 알리는 것과 달리, 선택은 일어난 순간을
2392
+ 알리는 것이라 되풀이할 까닭이 없다. 마지막 프레임이 `opacity: 0` 이라 되풀이를
2393
+ 끊는 것만으로 그대로 사라진다(실측: `animation-iteration-count: 1`).
2394
+
2395
+ - **피드백을 보이는 동안에는 걸리지 않는다.** 그때는 정답이 이미 울리고 있어서,
2396
+ 고른 것까지 울리면 무엇을 가리키는 울림인지 흐려진다.
2397
+
2398
+ ## 0.17.0
2399
+
2400
+ ### 바뀜 — 배지 자동 배율이 이제 **꺼짐이 기본**이다 (`scaleShortcut`)
2401
+
2402
+ - 0.15.0~0.16.0 에서는 `clamp(0.75, buttonSize / 56, 3)` 이 **묻지 않고 늘 걸렸다.**
2403
+ 끌 방법이 없어, 배지를 24px 그대로 두고 싶은 소비자에게는 길이 없었고 큰 버튼에서
2404
+ 배지가 갑자기 커지는 것이 놀라움이 됐다. 상한이 문제가 아니라 **자동으로 걸린다는
2405
+ 것 자체**가 문제였다.
2406
+
2407
+ ```jsx
2408
+ <SoundCompass speakers={speakers} /> // 배지 24px 그대로
2409
+ <SoundCompass speakers={speakers} scaleShortcut /> // 버튼 따라 키움
2410
+ <SoundCompass speakers={speakers} shortcutScale={1.6} /> // 직접 정함(위와 무관)
2411
+ ```
2412
+
2413
+ 둘 다 안 주면 배율 변수를 **아예 얹지 않는다** — CSS 기본값 1 이 살아 24px 이다
2414
+ (실측: 168px 버튼에서 기본 24px, `scaleShortcut` 을 켜면 72px).
2415
+
2416
+ - **깨지는 소비자를 먼저 셌다.** 배지(`SpeakerPos.shortcut`)는 0.15.0 에 생겼고,
2417
+ 지금 쓰는 곳은 소리방향성 한 곳인데 거기는 `shortcutScale` 을 명시한다. kscan 은
2418
+ `ShortcutBadge` 를 홑으로 쓰고(24곳) `SoundCompass` 는 쓰지 않으니 무관하다.
2419
+ speechear-v2 는 `SoundCompass` 를 쓰지만 `shortcut` 을 주지 않아 배지가 없다.
2420
+ 그래서 minor 로 낸다 — 화면이 달라지는 소비자가 지금은 없다.
2421
+
2422
+ ### 더해짐 — `--scb-idle-hover-fg`
2423
+
2424
+ - 호버가 배경·테두리만 바꾸고 글자색은 안 건드려서, 호버에서 흰 글자를 쓰려면
2425
+ 소비자가 `--scb-idle-fg` 까지 함께 덮어야 했다. 기본값은 `--scb-idle-fg` 라
2426
+ 주지 않으면 예전 그대로다.
2427
+
2428
+ ## 0.16.0
2429
+
2430
+ ### 더해짐 — 배지 크기를 부르는 쪽이 정할 수 있다
2431
+
2432
+ - `ShortcutBadge` 에 `scale`, `SoundCompass` 에 `shortcutScale` 을 연다. 안 주면
2433
+ 예전대로 버튼 크기에서 스스로 정한다(`clamp(0.75, buttonSize / 56, 3)`).
2434
+
2435
+ **상한 숫자를 올리는 것으로는 풀리지 않는 문제라 열었다.** 1.6 을 3 으로 올려도
2436
+ 소비자는 다시 그 끝에 붙을 수 있고, 다음 소비자는 또 다른 값에서 막힌다. 코어가
2437
+ 정한 식이 안 맞는 자리에서는 직접 주는 편이 낫다.
2438
+
2439
+ - CSS 로 덮는 것으로는 부족했다. 배율을 `transform` 으로 걸면 배지를 모서리에
2440
+ 걸치게 하는 `translate` 를 통째로 지운다 — 자리만 바꾸려던 규칙이 배율을 지우고
2441
+ 배율만 바꾸려던 규칙이 자리를 지운다. 이 prop 은 `--shortcut-badge-scale` 로
2442
+ 내려가므로 둘이 함께 산다(실측: 배율 2.6 을 줘도 `matrix(2.6,0,0,2.6,6,-6)` 로
2443
+ translate 가 살아 있다).
2444
+
2445
+ ## 0.15.1
2446
+
2447
+ ### 고침 — 배지의 자리와 배율이 서로를 지우던 것
2448
+
2449
+ - **0.15.0 이 낸 회귀다.** 배지 배율을 `transform: scale()` 로 걸었는데, 배지가
2450
+ 모서리에 걸치는 `translate` 와 **같은 속성**이라 뒤에 오는 쪽이 앞의 것을 통째로
2451
+ 지운다. 실측하니 계산된 값이 `matrix(0.75,0,0,0.75,0,0)` 으로 translate 가
2452
+ 사라져 있었다 — 배지가 모서리에 걸치지 않고 버튼 안으로 들어왔다(걸침 +6px → −3px).
2453
+
2454
+ 소비 앱은 반대편에서 같은 벽을 만났다. 자리를 바꾸려고 `translate` 를 다시 쓰면
2455
+ 이번엔 배율이 날아간다(38px → 24px).
2456
+
2457
+ 배율을 `--shortcut-badge-scale` 변수로 넘기고 **`ShortcutBadge` 가 제 anchor 규칙
2458
+ 안에서 둘을 합쳐 적는다.** 한 곳에서만 `transform` 을 쓰므로 지워질 일이 없다.
2459
+
2460
+ ### 더해짐 — `shortcutAnchor`
2461
+
2462
+ - 배지가 붙는 모서리를 고를 수 있다(기본 `top-right`). 예전에는 `top-right` 가
2463
+ 못박혀 있어 앱 CSS 로 덮는 수밖에 없었고, 덮다가 위의 함정을 밟게 됐다.
2464
+
2465
+ ### 고침 — 배지 배율 상한 1.6 → 3
2466
+
2467
+ - 1.6 은 `buttonSize` 89.6px 부터 붙어버려, 그보다 큰 버튼에서 배지가 다시 작아졌다
2468
+ (소비 앱 실측: 104px 에서 0.37, 138px 에서 0.28, 168px 에서 0.23).
2469
+
2470
+ 상한 3 이면 168px 까지 묶이지 않고, 그 구간에서 비율이 **0.43 으로 일정**하다
2471
+ (실측: 104·138·168 모두 0.43). 배율식이 `24/56` 이라 묶이지 않는 동안은 비율이
2472
+ 상수다. 하한 0.75 는 배지가 읽히는 크기라 그대로 둔다.
2473
+
2474
+ ## 0.15.0
2475
+
2476
+ ### 더해짐 — 스피커에 자판 키 배지(`speakers[].shortcut`)
2477
+
2478
+ - 스피커 버튼은 `SoundCompass` 가 직접 그리고 `children` 도 렌더 슬롯도 없어서,
2479
+ 앱에서 배지를 얹을 자리가 없었다. `SpeakerPos.shortcut` 을 주면 그 버튼 모서리에
2480
+ `ShortcutBadge` 가 얹힌다.
2481
+
2482
+ 키가 모자란 스피커에는 주지 않으면 된다 — 배지도 안 붙는다(예: 27개 배치의
2483
+ 가운데 층은 13개인데 자판 줄은 9칸이라 넷은 키가 없다).
2484
+
2485
+ 무엇을 눌렀는지 판정하는 일은 부르는 쪽 몫이다. 한글 입력 상태에서는
2486
+ `event.key` 가 "ㅈ" 이 되므로 `event.code`(늘 `KeyW`)로 보는 편이 안전하다.
2487
+
2488
+ - **배지가 버튼과 함께 자란다.** `ShortcutBadge` 는 24px 고정인데 버튼은 40~112px 을
2489
+ 오간다. 그대로 두면 버튼 대비 **0.60배에서 0.21배까지 세 배가 흔들려**, 작은
2490
+ 버튼에서는 배지가 버튼을 덮고 큰 버튼에서는 잃어버린다(실측). 버튼과 함께
2491
+ 키우되 양끝을 묶어 그 구간에서 **0.45~0.34배**에 머문다(실측).
2492
+
2493
+ 40~112px 을 벗어나면 다시 벌어진다(24px 에서 0.75배, 200px 에서 0.19배). 배지가
2494
+ 읽히는 최소 크기가 있어 더 줄이지 않는다.
2495
+
2496
+ - 배지는 클릭을 가로채지 않는다(`pointer-events: none`). 눌러야 하는 것은 버튼이다.
2497
+
2498
+ ## 0.14.0
2499
+
2500
+ ### 고침 — SoundCompass 를 마우스 없이도 쓸 수 있게
2501
+
2502
+ - **키보드로는 응답할 방법이 아예 없었다.** `onClick` 이 바깥 `<div>` 에 있고
2503
+ `<button>` 은 `tabIndex={-1}` 이 못박혀 있었다. 그 div 에는 role 도 키 핸들러도
2504
+ 없었으니 동작한 것은 버튼 클릭이 div 로 버블링된 덕일 뿐이다 — **Tab 으로는
2505
+ 어떤 스피커에도 닿지 못했다.** 나침반을 기본 응답 화면으로 쓰는 앱에서는
2506
+ 마우스가 없으면 검사에 응답할 길이 없었다는 뜻이다.
2507
+
2508
+ 고르는 일을 버튼 자신에게 옮기고, `clickable` 일 때 `tabIndex` 를 0 으로 연다.
2509
+ 네이티브 `<button>` 이 Enter·Space 를 스스로 클릭으로 바꾸므로 키 핸들러는
2510
+ 달지 않는다 — 달면 Space 가 두 번 발동한다. 고를 수 없을 때는 `tabIndex={-1}`
2511
+ 에 `aria-disabled` 를 준다.
2512
+
2513
+ **이 판이 minor 인 까닭이 여기 있다.** `clickable` 을 쓰는 화면에 탭 정지점이
2514
+ 새로 생긴다. 고침이지만 소비자의 탭 순서가 달라지므로 스스로 받으러 오게 둔다.
2515
+
2516
+ ### 더해짐 — 호버·포커스 상태
2517
+
2518
+ - `.sound-compass__speaker-btn` 이 `all: unset` 으로 시작해 브라우저 기본 상태까지
2519
+ 지워져 있었다(실측: 이 파일에 `:hover`·`:focus` 규칙이 0개였다). 커서만
2520
+ pointer 로 바뀔 뿐, 눌러도 되는 자리인지 눈으로는 알 수 없었다.
2521
+
2522
+ 상태 색과 같은 방식으로 변수를 연다 — `--scb-idle-hover-bg`,
2523
+ `--scb-idle-hover-border`, `--scb-focus-ring`.
2524
+
2525
+ 링은 `:focus-visible` 이라 **마우스로 누를 때는 안 뜨고 키보드로 옮길 때만**
2526
+ 뜬다.
2527
+
2528
+ ## 0.13.2
2529
+
2530
+ ### 고침 — SoundCompass 의 링 연결선을 직선에서 호로
2531
+
2532
+ - 스피커는 탑뷰에서 **한 원 위에** 놓이는데 이웃을 `L`(직선)로 이어 왔다. 작은
2533
+ 나침반에서는 티가 안 났지만 크게 키우면 꺾인 다각형이 그대로 보인다.
2534
+
2535
+ 현이 호 안쪽으로 들어가는 깊이(실측·계산 일치):
2536
+
2537
+ | 배치 | 간격 | size 300 (R=110) | size 1385·row 2 (R=757) |
2538
+ |---|---|---|---|
2539
+ | 5개·7개 | 30° | 3.75px | **25.8px** |
2540
+
2541
+ > 처음 이 자리에 "5개 배치는 45° 간격이라 57.63px" 이라고 적었는데 **틀렸다.**
2542
+ > 소비 앱의 HORIZON_5 는 −60/−30/0/+30/+60 이라 30° 간격이고, 45°짜리 배치는
2543
+ > 없다. 실측이 아니라 "5개니까 180°를 4등분" 이라는 어림에서 나온 값이었다.
2544
+ > 45° 수치 자체는 산식으로는 맞지만 **쓰이는 배치가 아니다.**
2545
+
2546
+ `A` 로 그리므로 근사가 아니라 **정확히 스피커 중심을 지난다**(브라우저 실측:
2547
+ 반지름 110 에서 path 위 21개 표본의 중심 거리 오차 0px).
2548
+
2549
+ - **정면뷰에서는 호로 두지 않는다.** 그쪽은 격자라 같은 row 가 같은 y 에 서는
2550
+ 곧은 가로줄이고, 호로 두면 틀린 그림이 된다. `viewT` 로 부풀림을 줄여
2551
+ t=1 에서 제어점이 현의 중간점과 같아져 곧은 선으로 무너진다(실측: 부풀림이
2552
+ 110 → 90.9 → 67.8 → 37.7 → **0px**).
2553
+
2554
+ - 링(row)마다 제 반지름으로 그린다. 층이 여럿인 배치에서 안쪽 링의 반지름을
2555
+ 바깥 링에 쓰지 않는다.
2556
+
2557
+ - 도는 방향은 배열 순서로 짐작하지 않고 **각도 차로 정한다** — speakers 를
2558
+ 거꾸로 넘기는 소비자에서도 바깥으로 부푼다.
2559
+
2560
+ - 반지름을 소수 셋째 자리에서 끊는다. `300 * (110/300)` 이 109.99999999999999
2561
+ 이라 그대로 실으면 호마다 그 긴 값이 적힌다.
2562
+
2563
+ ## 0.13.1
2564
+
2565
+ ### 더해짐 — ProgressIndicator 의 `ariaLabel`
2566
+
2567
+ - **이름표를 직접 줄 수 있다.** 안 주면 예전대로 i18n 의 `progress.steps` 로
2568
+ 짓는다.
2569
+
2570
+ i18n 을 쓰지 않는 앱에서는 그 값이 한글 기본값("진행 단계 1 / 4")으로
2571
+ 떨어진다 — 화면은 영문인데 이름표만 한글이 되는 자리가 실제로 있었다
2572
+ (sound-localization-system 의 병원용 빌드는 화면 문구를 자체 copy 객체로
2573
+ 가르고 i18n 을 쓰지 않는다).
2574
+
2575
+ ## 0.13.0
2576
+
2577
+ ### 더해짐 — ProgressIndicator 의 점 모드(`variant="dot"`)
2578
+
2579
+ - **번호 없는 8px 점.** 지금 칸만 알약(24px)으로 늘어난다. 기본값
2580
+ `numbered`(52×28 번호 칸)는 그대로다.
2581
+
2582
+ 번호 칸은 "지금 몇 번째인지" 를 글로 읽어야 하는 자리(훈련 팝업)를 위한
2583
+ 것이라, 화면에서 멀리 떨어져 보는 안내에는 너무 크다 —
2584
+ sound-localization-system 의 검사 시작 안내가 그래서 점을 직접 그리고
2585
+ 있었다. 좁은 화면에서도 접지 않는다(점은 이미 작다).
2586
+
2587
+ 칸을 폭으로도 가른다 — 색만으로 가르면 색을 구별하기 어려운 사람에게 네
2588
+ 칸이 같아 보인다. 폭 전환은 `prefers-reduced-motion` 을 따른다.
2589
+
2590
+ 낭독은 묶음 하나로 한다(`role="img"` + 이름표). 번호가 없어 칸마다 읽을
2591
+ 것이 없는데 `role="list"` 로 두면 "목록, 4개, 빈 항목…" 이 된다.
2592
+ 화면이 스스로 넘어가는 자리라면 `announce` 로 `role="status"` 를 켠다.
2593
+
2594
+ ### 더해짐 — Table 머리의 아이콘(`icon`)
2595
+
2596
+ - **표 제목 앞에 lucide 아이콘을 세운다.** `Panel` 의 `icon` 과 같은 자리·같은
2597
+ 크기(20px)·같은 색(`--t-item-fg`, 기본 브랜드색)이다.
2598
+
2599
+ 이 자리가 없어서 부르는 쪽이 아이콘을 `title` 노드 안에 넣고 있었다 —
2600
+ 표와 판넬을 나란히 놓는 화면(sound-localization-system 의 검사 설정)에서
2601
+ 두 머리 모양이 미묘하게 갈렸다.
2602
+
2603
+ ### 바뀜 — 설명이 붙은 Panel 머리는 제목을 한 급 내린다
2604
+
2605
+ - **`disc` 가 있으면 `.panel__title` 이 `h5b`(15px) 대신 `body-3b`(14px)** 로
2606
+ 나온다. 설명(caption-1 12px)과 행 라벨(body-3 14px)이 모두 14px 이하인데
2607
+ 제목만 15px 이면 한 칸 차이가 어중간해, 제목이 커 보이기보다 급이 하나 더
2608
+ 있는 것처럼 읽힌다. 굵기(600)가 위계를 낸다.
2609
+
2610
+ **설명이 없는 판넬은 예전 급(h5b) 그대로다** — 제목만 선 머리는 그 자체로
2611
+ 한 줄이라 크기가 위계를 낸다.
2612
+
2613
+ 소비 앱이 `.panel__title` 을 통째로 덧칠해 낮추고 있었다면 이제 그 규칙을
2614
+ 걷을 수 있다. 다만 한 화면에 설명 있는 칸과 없는 칸이 섞이면 제목 급도
2615
+ 섞이므로, 그럴 때는 설명을 채우거나 덧칠을 유지한다.
2616
+
2617
+ ## 0.12.3
2618
+
2619
+ ### 적어둠 — `iconRenderer-*` 는 붙잡을 이름일 뿐이다 (Icon props 문서)
2620
+
2621
+ - `IconProps` 에는 주석이 하나도 없었다. 채우면서 두 가지를 적었다.
2622
+
2623
+ **넷 다 제 규칙이 없다.** `iconRenderer-icon` · `-image` · `-mask` · `-svg` 는
2624
+ 이 패키지에 자기 규칙이 **0개**다(실측). 브라우저로도 확인했다 — 맨
2625
+ `<span class="iconRenderer-icon">` 의 계산된 display 가 그냥 `inline` 이다.
2626
+ 그래서 `className="iconRenderer-icon"` 이라고 적어도 아무 모양도 안 따라온다.
2627
+ 크기는 `size`, 그 밖은 `wrapperStyle` 이나 소비자 CSS 가 정한다.
2628
+
2629
+ 패키지 안에서 이 이름을 붙잡는 자리는 딱 한 곳인데 그것도 규칙이 없어서 생겼다 —
2630
+ `.audio-player__no-waveform .iconRenderer-icon` 이다. 래퍼가 줄 높이만큼 커지는
2631
+ 것을 그 자리에서 바로잡는다(실측: 24×28 → 그 고침을 얹으면 24×24).
2632
+
2633
+ **`className` 은 기본 이름을 갈아치운다** — 덧붙이지 않는다. 꾸민 래퍼는
2634
+ 패키지가 더는 이름 붙이지 않는 래퍼가 된다. 소비자가 이미 쓰고 있는 길이다.
2635
+
2636
+ - 코드는 바뀌지 않았다. `.d.ts` 에 설명이 실려 IDE 툴팁으로 닿는 것이 이 판의 값이다.
2637
+
2638
+ ## 0.12.2
2639
+
2640
+ ### 고침 — 아이콘을 준 판넬 머리에서 제목이 오른쪽 끝으로 밀리던 것
2641
+
2642
+ - 0.12.1 에서 연 `icon` 이 실제로는 서지 못했다. `.panel__header` 는 actions 를
2643
+ 오른쪽으로 보내려고 `justify-content: space-between` 인데, 아이콘이 서면 머리의
2644
+ 자식이 [아이콘, 제목묶음] 이 되어 **제목묶음이 오른쪽 끝까지 밀린다** —
2645
+ 아이콘과 제목 사이가 실측 **219px**, actions 까지 있으면 **653px** 벌어졌다.
2646
+
2647
+ 제목묶음이 남는 자리를 먹게 해서(`margin-right: auto`) 제목이 아이콘 옆
2648
+ 20px 에 서고 actions 는 오른쪽 20px 을 그대로 지킨다. 아이콘이 없는 머리
2649
+ 19개는 이 규칙 앞뒤로 위치가 **완전히 같다**(실측).
2650
+
2651
+ 구조를 보는 시험은 이것을 못 잡았다 — jsdom 에 레이아웃이 없어서, 아이콘이
2652
+ 묶음 밖에 선 것만 맞히고 그 자리가 틀린 것은 볼 수 없었다. 그래서 규칙 자체를
2653
+ 재는 시험을 따로 붙였다(`test/panelHeaderIconLayout.test.ts`).
2654
+
2655
+ ## 0.12.1
2656
+
2657
+ ### 더해짐 — `armchair` 아이콘
2658
+
2659
+ - 정적 목록에 의자를 더한다. "가운데 의자에 앉으세요" 같은 안내에 쓸 그림이
2660
+ 없어 소비 앱이 map-pin(위치 표시)으로 대신하고 있었다 — 자리를 가리키는
2661
+ 그림이지 앉으라는 그림이 아니다.
2662
+
2663
+ ### 더해짐 — Panel 머리의 아이콘(`icon`)
2664
+
2665
+ - **제목 앞에 lucide 아이콘을 세운다.** `Section` 의 `icon` 과 같은 자리·같은
2666
+ 크기(20px)·같은 색(`--t-item-fg`, 기본 브랜드색)이다.
2667
+
2668
+ 이 자리가 없어서 부르는 쪽이 아이콘을 `title` 노드 안에 넣고 있었는데,
2669
+ 그러면 **설명(`disc`)이 아이콘 밑으로 들어가 제목 글자와 왼쪽 선이 어긋난다.**
2670
+ 아이콘을 제목·설명 묶음 밖에 세워 둘의 왼쪽을 맞춘다.
2671
+
2672
+ ### 고침 — 머리만 있는 판넬 바닥에 줄이 두 개로 보이던 것
2673
+
2674
+ - 행이 하나도 없는 판넬에서도 머리가 아래 구분선을 그리고 있었다. 판넬 자신의
2675
+ 테두리와 1px 을 사이에 두고 겹쳐 **얇은 줄이 두 개**로 보였다(실측: 그런
2676
+ 판넬의 높이가 58px — 머리 56 + 선 1 + 테두리 1). 머리가 마지막 요소면
2677
+ 구분선을 그리지 않는다.
2678
+
2679
+ ### 고침 — `disc` 를 준 판넬 머리만 키가 커지던 것
2680
+
2681
+ - 설명이 붙은 머리에 위아래 여백(`padding-block: 12px`)을 주고 있었다. 그만큼
2682
+ 머리만 커져서, **부제(`sub`)가 붙어도 높이가 그대로인 행과 어긋났다**
2683
+ (실측: 56 → 74). 여백을 걷는다 — 제목+설명 두 줄은 49px 이라 `medium`(56)·
2684
+ `large`(64) 안에 그대로 들어가고, 머리는 다시 size 스케일을 따른다.
2685
+
2686
+ `small`(48) 에서만 두 줄이 살짝 넘친다.
2687
+
2688
+ - 같은 이유로 **설명의 글자 급을 `body-3` 에서 `caption-1` 로 내린다** — 행의
2689
+ 부제(`panel-row__sub`)와 한 화면에 함께 서는데 급이 달라 층이 하나 더 있어
2690
+ 보였다. `Section` 의 `disc` 는 body-3 그대로다(그쪽은 판넬 밖 제목이라 옆에
2691
+ 견줄 곁글이 없다).
2692
+
2693
+ ## 0.12.0
2694
+
2695
+ ### 더해짐 — Panel 머리의 설명(`disc`)
2696
+
2697
+ - **판넬 머리에 제목 아래 한 줄 설명을 둔다.** `Section` 의 `disc` 와 같은 자리·같은
2698
+ 급(`body-3`, `--color-gray`)이다.
2699
+
2700
+ 머리가 제목 한 줄뿐이라, 칸이 무엇을 하는 자리인지 적으려면 부르는 쪽이 설명을
2701
+ 첫 행(`PanelRow variant="description"`)으로 내려야 했다 — 고르는 것이 없는 행이
2702
+ 하나 늘고 행 높이(56px)까지 차지한다. 설명을 `title` 에 끼워 넣는 것도 답이
2703
+ 아니다: 제목은 `<h2>` 안에 그려지므로 스크린리더가 설명까지 제목으로 읽는다.
2704
+
2705
+ 설명을 주면 머리가 `.panel__header--with-disc` 를 받아 위아래 여백을 갖는다
2706
+ (두 줄이라 size 의 min-height 를 넘어선다). **설명을 주지 않으면 예전과 같은
2707
+ 한 줄 머리 그대로다** — 제목과 설명을 `.panel__title-group` 으로 묶었을 뿐이라
2708
+ `actions` 도 오른쪽 자리를 지킨다.
2709
+
2710
+ 제목 없이 설명만 줘도 머리가 선다.
2711
+
2712
+ ## 0.11.1
2713
+
2714
+ ### 고침 — ReportDocument 바닥의 IHAB 로고가 Next 앱에서 깨지던 것
2715
+
2716
+ - 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, **Next 는 `{ src }` 객체**다.
2717
+ 문자열만 보고 있어서 Next 앱에서는 fallback 경로(`/logo/Logo_IHAB_en.svg`)로 떨어져
2718
+ 404 가 났다(kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다.
2719
+
2720
+ 같은 패턴이 `Header` 에도 있다 — 그쪽은 아직 손대지 않았다.
2721
+
2722
+ ## 0.11.0
2723
+
2724
+ ### 더해짐 — SummaryStrip
2725
+
2726
+ - **화면 위쪽에 놓는 한 줄 요약 컴포넌트.** "지금 다루고 있는 대상"을 이름표와 값으로
2727
+ 늘어놓고, 오른쪽 끝에 버튼(수정·종료 등)을 붙인다. kscan 의 피검사자 요약 스트립을
2728
+ 옮겨 왔다.
2729
+
2730
+ 값 길이가 제각각이라 균등 그리드에 앉힌다 — 그냥 나열하면 칸 간격이 들쭉해진다.
2731
+ 칸 수는 넓이에 따라 접힌다: **1280px 이상 `columns`(기본 항목 수) → 1024px 이상 4 →
2732
+ 640px 이상 3 → 그 아래 2.** 칸에 안 들어가는 값은 칸을 밀어내지 않고 말줄임된다.
2733
+
2734
+ 값의 서식은 부르는 쪽이 정한다 — 빈 값을 `-` 로 적을지, 여러 값을 한 칸에 묶을지
2735
+ (`오른손 / NC / 연구실 내`)는 화면마다 다르다. 붙박이(sticky)로 둘지도 감싸는 쪽이
2736
+ 정한다.
2737
+
2738
+
2739
+ ### 고침 — 제목 태그의 타이포가 리셋에 지워지던 것
2740
+
2741
+ - **`h1`~`h6` 에 타이포 헬퍼를 붙였을 때만 특이도를 0-1-0 으로 올린다.**
2742
+
2743
+ 0.10.0 이 헬퍼를 특이도 0 으로 낮추면서, 앱 클래스뿐 아니라 **태그 선택자에도
2744
+ 지게 됐다.** Tailwind preflight 의
2745
+ `h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit }` (0-0-1) 이
2746
+ 헬퍼를 이겨, `<h1 class="h1">` 이 32px/700 이 아니라 본문값(16px/400)으로 나왔다.
2747
+ preflight 가 건드리지 않는 `line-height` 만 헬퍼 값이 남아 "작고 안 굵은데 행간만
2748
+ 큰" 제목이 됐다 — kscan 의 화면 제목이 전부 그랬다(`PageTitle` 이 h1 을 그린다).
2749
+
2750
+ 그래서 **제목 태그일 때만** 클래스의 `:where()` 를 벗는다:
2751
+ `:where(.ihabdevteam-core-scope) :where(h1, h2, h3, h4, h5, h6).h1` — 0-1-0 이다.
2752
+
2753
+ | 규칙 | 특이도 | 결과 |
2754
+ | --- | --- | --- |
2755
+ | 리셋의 `h1` | 0-0-1 | 진다 |
2756
+ | 이 규칙 | 0-1-0 | 이긴다 |
2757
+ | 앱의 `.lp-hero-title` | 0-1-0 | **동점 — 나중에 실린 쪽이 이긴다** |
2758
+
2759
+ 앱 CSS 는 디자인 시스템 뒤에 실리므로 앱이 계속 이긴다. 다만 순서에 기대므로,
2760
+ 코어보다 **먼저** 싣는 곳이라면 두 겹으로 적어야 한다.
2761
+
2762
+ 되돌리는 것은 리셋이 지우는 `font-size`·`font-weight` 둘뿐이다. `margin` ·
2763
+ `line-height` · `letter-spacing` 은 특이도 0 그대로라, 0.10.0 이 고친 히어로
2764
+ 여백(`margin-bottom: 32px`) 문제는 그대로 고쳐진 채 남는다. 제목 태그가 아닌
2765
+ 곳(`p` · `div` · `span`)은 이 규칙에 걸리지 않아 완전히 0.10.0 과 같다.
2766
+
2767
+ 회귀 시험(`test/typographySpecificity.test.ts`)에 모양과 속성 범위를 못박았다 —
2768
+ 더 올리거나 `margin` 까지 되돌리면 시험이 깨진다.
2769
+
2770
+ ## 0.10.0
2771
+
2772
+ ### 바뀜 — 소비 앱의 여백·크기가 되살아난다 (받기 전에 읽을 것)
2773
+
2774
+ - **전역 타이포 헬퍼(`.display-*` · `.h0`~`.h5` · `.body-*` · `.button-*` ·
2775
+ `.caption-*`)를 `:where()` 로 감싸 특이도를 0 으로 낮췄다.**
2776
+
2777
+ 이 번들은 소비자 앱과 섞이지 않게 모든 셀렉터에 `.ihabdevteam-core-scope` 를 붙여
2778
+ 내보낸다. 그러다 보니 `.h0` 이 `.ihabdevteam-core-scope .h0`(0-2-0)이 되어, 앱이
2779
+ `.lp-hero-sub`(0-1-0) 한 겹으로 적은 여백·크기를 **통째로 눌러 이기고 있었다.**
2780
+ `styles.css` 는 "margin 은 0 기본, 필요 시 컴포넌트에서 override" 라고 적어 두고도
2781
+ 그 override 가 성립하지 않았다.
2782
+
2783
+ 실측 피해 — 회원앱 랜딩 한 화면에서 다섯 자리가 눌렸고 **그중 셋이 코어 자신의
2784
+ 컴포넌트**였다(`payment-plan__quote` · `payment-plan__subtitle` ·
2785
+ `site-footer__copyright`). 히어로에서는 `margin-bottom: 32px` 가 0px 이 되어 제목·
2786
+ 본문·단추가 맞붙었고, 모바일에서 앱이 정한 26px 제목이 h0 의 28px 로 되돌아갔다.
2787
+
2788
+ **받으면 무엇이 달라지나** — 그동안 눌려 있던 앱 규칙이 살아난다. 여백만이 아니라
2789
+ 크기 오버라이드도 함께다. 대개는 앱이 원래 의도한 값이 돌아오는 것이지만, 눌린
2790
+ 상태에 맞춰 화면을 다듬어 두었다면 그 자리는 다시 볼 것. 앱이 특이도를 올려
2791
+ 임시로 이겨 두었던 보정(`.부모 .자식` 같은 조상 한 겹)은 이제 떼어도 된다.
2792
+
2793
+ 고침은 소스와 빌드 **양쪽**에 걸쳐 있다 — `styles.css` 가 `:where()` 로 적고,
2794
+ `postcss.config.cjs` 가 프리픽스도 `:where()` 로 감싼다. 둘 중 하나만 풀려도
2795
+ 특이도가 되살아나 같은 사고가 조용히 돌아오므로, `typographySpecificity.test.ts`
2796
+ 가 소스·설정·번들 셋을 모두 잰다.
2797
+
2798
+ ### 더함
2799
+
2800
+ - **디스플레이 단 `.display-1` ~ `.display-3`.** `h0`(40px) **위**의 세 단으로,
2801
+ 랜딩·히어로처럼 크게 외치는 자리 전용이다. 앱 화면 제목에는 그대로 `h0`~`h5` 를 쓴다.
2802
+
2803
+ | | 크기 | 행간 | 자간 | 굵기(`b`) |
2804
+ |---|---|---|---|---|
2805
+ | `display-1` | `clamp(44px, 6.6vw, 72px)` | 1.05 | -0.04em | 800 (900) |
2806
+ | `display-2` | `clamp(38px, 5.2vw, 56px)` | 1.12 | -0.03em | 700 (800) |
2807
+ | `display-3` | `clamp(34px, 4.1vw, 44px)` | 1.2 | -0.02em | 700 (800) |
2808
+
2809
+ h 단과 규약이 셋 다르고, 그 다름 자체를 시험으로 고정했다 —
2810
+ 크기는 **clamp(뷰포트 연속 보간)** 이다(h 단처럼 계단을 뛰면 그 폭 근처에서 히어로의
2811
+ 줄바꿈이 툭툭 달라진다. 그래서 반응형 블록에 다시 적지 않는다).
2812
+ 행간은 **배수**, 자간은 **em** 이다(크기가 연속으로 변하는데 px 로 박으면 큰 화면에서
2813
+ 행간이 붙는다). 글자 크기 스케일(`[data-font-scale]`)에서는 **뺀다**(`h0`~`h3` 과 같은
2814
+ 까닭 — 이미 크고, 키우면 히어로 한 줄이 화면을 넘긴다).
2815
+
2816
+ **어느 폭에서도 `h0 < display-3 < display-2 < display-1`** 이 성립한다. `h0` 는
2817
+ 계단을 뛰고(28→32→40) display 는 연속이라 계단이 뛰는 바로 그 폭에서만 역전이
2818
+ 나는데, 실제로 1024px 이 그 자리였다 — `display-3` 의 vw 계수(4.1)는 거기서 40 을
2819
+ 넘도록 잡은 값이고, 시험이 열두 폭을 훑어 고정한다.
2820
+
2821
+ 토큰도 함께 열린다: `--text-display-{1,2,3}-{size,line,spacing,weight,weight-bold}`.
2822
+
2823
+ - **`ReportDocument` · `ReportInfoPanel`** — 보고서 문서 컴포넌트.
2824
+
2825
+ ### 고침
2826
+
2827
+ - 플레이그라운드 타이포 페이지가 실제 스케일 클래스를 보여 준다. 예전에는 비슷한
2828
+ 수치를 박아 둔 `typo-*` 클래스를 썼는데, `typo-display` 가 40px 이라 `h0` 와 같은
2829
+ 값을 'Display' 라고 부르고 있었다.
2830
+
2831
+ ## 0.9.11
2832
+
2833
+ ### 더함
2834
+
2835
+ - **`@ihabdevteam/core/fonts.css` — Pretendard 웹폰트 진입점.** `--font-family` 는
2836
+ `'Pretendard Variable'` 을 맨 앞에 두는데 그 이름을 선언하는 `@font-face` 가 패키지
2837
+ 어디에도 없었다(`tokens.css`·`styles.css` 모두 0개). 그래서 폰트를 따로 싣지 않은
2838
+ 앱은 스택을 타고 내려가 `system-ui` 로 렌더된다.
2839
+
2840
+ 이 실패는 소리가 나지 않는다 — 콘솔은 조용하고, 개발 기기에 Pretendard 가 깔려
2841
+ 있으면 두 번째 후보로 매칭돼 정상처럼 보인다. **폰트가 없는 일반 사용자에게만**
2842
+ 다른 글자가 나간다. 타입 사다리는 토큰에서 오므로 크기·행간·자간은 다 맞고 글자
2843
+ 모양만 다르다. 실제로 소리방향성시스템이 이 상태로 배포 직전까지 갔다.
2844
+
2845
+ 이제 진입점에서 한 줄이면 된다:
2846
+
2847
+ ```tsx
2848
+ import '@ihabdevteam/core/fonts.css'
2849
+ ```
2850
+
2851
+ `styles.css`·`tokens.css` 에 묶지 않은 이유는 폰트가 무겁고 이미 자체 경로로
2852
+ Pretendard 를 받는 앱도 있어서다 — 필요한 쪽만 지불한다. 받는 것은 가변 폰트의
2853
+ 동적 서브셋이라 화면에 나온 글자 구간만 내려오고, 번들러가 woff2 를 산출물에
2854
+ 넣으므로 오프라인 데스크톱에서도 뜬다.
2855
+
2856
+ `pretendard` 는 **선택 peerDependency** 다. 설치본이 97MB 라 `dependencies` 에 두면
2857
+ 이 파일을 쓰지 않는 소비자와 그들의 CI 까지 매번 그 값을 치른다 — 따로 뺀 취지와
2858
+ 어긋난다. 설치하지 않은 채 import 하면 번들러가 경로를 못 찾아 빌드가 멈춘다.
2859
+
2860
+ 이 파일만 postcss 를 태우지 않고 그대로 복사한다 — `@import` 가 살아 있어야 소비자
2861
+ 번들러가 `pretendard` 를 제 위치에서 풀고 `url()` 을 제 산출물 기준으로 다시 쓴다.
2862
+ `postcss-import` 가 내용을 펼치면 그 경로가 core 기준이 되어 깨진다.
2863
+
2864
+ `CONSUMING.md` 3-5 에 확인 방법까지 적었다.
2865
+
2866
+ ## 0.9.0
2867
+
2868
+ ### 고침
2869
+
2870
+ - **`i18next` · `react-i18next` 를 번들에 넣지 않고 소비자 것을 쓴다(external).**
2871
+ 크기가 아니라 **인스턴스 동일성** 때문이다. `src/i18n/index.ts` 는 i18next 의 기본
2872
+ 인스턴스를 쓰는데, 번들마다 i18next 를 인라인하면 그 기본 인스턴스가 번들 수만큼 생겼다 —
2873
+ `ihabdevteam-core.cjs.js` · `components.cjs.js` · `i18n.cjs.js` 가 각각 한 벌씩
2874
+ 갖고 있었다(내부 심볼 30개, 외부 require 0건). 그래서 `./i18n` 에 `addResourceBundle` 로
2875
+ 앱 사전을 얹어도 컴포넌트가 보는 인스턴스가 달라 **오류 없이 아무 일도 안 일어났다.**
2876
+
2877
+ 둘 다 `dependencies` 라 소비자에게 반드시 설치되어 있어 해석에 문제가 없다.
2878
+ 설치본에서 확인했다 — 메인·components·`/i18n` 셋을 섞어 불러도 소비자의 `i18next` 와
2879
+ 같은 인스턴스이고, 얹은 사전이 밖에서 읽히며, 코어 문구도 그대로다.
2880
+
2881
+ - **`./i18n` 을 ESM `import` 로도 부를 수 있다.** 0.8.2 에서 `require` 만 살렸고 ESM 파일은
2882
+ 그대로여서 `import()` 는 여전히 `ERR_IMPORT_ATTRIBUTE_MISSING` 이었다. 로케일 JSON
2883
+ import 에 `with { type: 'json' }` 을 붙여 원인을 없앴다. **번들본을 가리키는 쪽은 택하지
2884
+ 않았다** — 그러면 모듈 인스턴스가 둘이 되어 `init()` 이 두 번 돈다. 같은 파일로 모으는
2885
+ 쪽이 옳다.
2886
+
2887
+ ### 번들 크기
2888
+
2889
+ components 859 → 810 KB · i18n 344 → 301 KB · 메인 1259 → 1210 KB (CJS 기준)
2890
+
2891
+ ## 0.8.3
2892
+
2893
+ ### 고침
2894
+
2895
+ - **`toStorageName` 이 NFC 로 모은다.** 파일을 **쓰는 쪽**(TTS·이미지 에이전트)은 낱말을
2896
+ NFC 로 모아 hex 이름으로 올리는데, **읽는 쪽**인 이 함수는 모으지 않고 있었다. NFC
2897
+ 입력에서는 같은 값이라 지금껏 안 물렸지만, NFD 가 한 번 들어오면 없는 이름을 찾는다 —
2898
+ **올린 쪽은 성공, 찾는 쪽은 없음, 오류는 안 남.** 소리와 이미지가 조용히 빠지는 자리다.
2899
+
2900
+ macOS 파일시스템과 일부 IME 가 NFD 를 내므로 사람이 붙여 넣은 낱말이 들어오는 경로에서
2901
+ 만난다. **NFC 입력은 바이트가 그대로라(정규화해도 같다) 이미 올라간 파일은 안 깨진다** —
2902
+ NFD 입력만 제자리를 찾는다. `officialSoundPath` 도 이 함수를 지나므로 함께 모인다.
2903
+
2904
+ 회귀 시험을 세웠다(`test/storageNameNfc.test.ts`). 그 시험이 첫판에 제 실수를 잡았다 —
2905
+ `officialSoundPath(voiceType, pronunciation)` 의 인자 차례를 뒤집어 넣었더니 "눈에 같은데
2906
+ 다르다" 가 나왔다(목소리는 hex 로 안 바뀌고 경로 앞에 그대로 붙는다).
2907
+
2908
+ ## 0.8.2
2909
+
2910
+ ### 고침
2911
+
2912
+ - **`./i18n` 을 Node 에서 부를 수 있게 한다.** 이 서브패스는 ESM 파일 하나만 가리키고
2913
+ 있었는데, 그 파일이 `import ko from './locales/ko.json'` 을 import attribute 없이 해서
2914
+ 순수 Node 에서 `ERR_IMPORT_ATTRIBUTE_MISSING` 으로 막혔다. 번들러(Vite·Next·vitest)는
2915
+ 처리하므로 지금 소비자들은 무사했지만, SSR 이나 Node 스크립트에서는 못 쓴다.
2916
+ 다른 엔트리들처럼 CJS 로도 묶어(`dist/i18n.cjs.js`) `require` 조건을 달았다 —
2917
+ JSON 이 번들에 인라인되어 attribute 문제가 사라진다.
2918
+
2919
+ ### 추가
2920
+
2921
+ - **컴포넌트 스타일이 어디서 오는지 적었다(CONSUMING.md §3-4).** `styles.css` 에는
2922
+ 컴포넌트 스타일이 없다 — 16KB 토큰·전역 묶음이고 `.popup-panel` 이 0회다. 각 컴포넌트
2923
+ 모듈이 제 CSS 를 사이드이펙트로 부른다(`components/*.js` 84개 중 69개).
2924
+ **그리고 그 개별 CSS 는 스코프가 안 붙는다** — 빌드가 postcss 없이 그대로 복사하기
2925
+ 때문이다(rollup 이 묶는 쪽만 붙는다, 2,344곳). 그래서 ESM 으로 쓰면 `.button` 이 호스트
2926
+ 앱 전역에 풀리고, 대신 스코프 클래스가 없어도 컴포넌트가 스타일을 받는다. 알려진
2927
+ 어긋남이지만 **고치지 않기로 했다.** 실측했을 때 실질 크기가 작았다 — ESM 은 쓰는
2928
+ 컴포넌트만 끌어오므로 1,376개가 한꺼번에 풀리지 않고, Tailwind 유틸리티와 겹치는 것은
2929
+ `.table` 하나이며 그것도 속성이 달라 무해하다. 반면 고치면 스코프 클래스 없이 잘 돌던
2930
+ ESM 소비자가 전부 조용히 깨진다 — 이 패키지가 여러 번 물렸던 그 방식이다.
2931
+ - **`./package.json` 을 exports 에 연다.** `require('@ihabdevteam/core/package.json')` 으로
2932
+ 버전을 읽는 흔한 진단이 `ERR_PACKAGE_PATH_NOT_EXPORTED` 로 막혀 있었다.
2933
+
2934
+ ### 문서
2935
+
2936
+ - **앱 문구를 더할 때 코어 배포를 기다릴 필요가 없다는 것을 적었다(CONSUMING.md §6-1).**
2937
+ 사전이 패키지 안에 있어 그래야 하는 것처럼 보이는데, `./i18n` 이 i18next 인스턴스를
2938
+ 그대로 주므로 `addResourceBundle` 로 앱 사전을 얹으면 된다. 코어 열쇠는 살아 있고 앱
2939
+ 열쇠가 나란히 붙는다(설치본에서 실측). **앱 전용 문구를 코어에 넣으면 소비자 셋의
2940
+ 문구가 한 패키지에 쌓이고 문구 한 줄에 배포가 필요해진다**는 것도 함께 적었다.
2941
+
2942
+ ## 0.8.1
2943
+
2944
+ ### 고침
2945
+
2946
+ - **`./i18n/locales/*` 서브패스를 연다.** 로케일 JSON 은 배포물에 들어 있었는데
2947
+ (`dist/packages/core/src/i18n/locales/{ko,en}.json`) exports 맵에 문이 없어
2948
+ `ERR_PACKAGE_PATH_NOT_EXPORTED` 로 막혔다. exports 맵이 있으면 맵에 없는 서브패스는
2949
+ 파일이 있어도 못 부른다.
2950
+
2951
+ `./i18n` 은 i18next **인스턴스**를 준다. 시험이 `i18n.createInstance()` 에 resources 로
2952
+ 사전 원본을 넣어 제 인스턴스를 세우는 경우에는 그것으로 대신할 수 없다 — 전역 인스턴스와
2953
+ 따로 두는 것이 목적이기 때문이다. speechear-v2 의 시험 일곱이 이 자리에서 막혔다.
2954
+
2955
+ ### 문서
2956
+
2957
+ - **CONSUMING.md §3 을 다시 썼다 — 잘못 읽히던 자리다.** 스코프 클래스가 필요한 것은
2958
+ `styles.css` 를 쓸 때뿐인데, 절 제목이 조건 없이 "필수"라 `tokens.css` 만 쓰는 소비자도
2959
+ 제 것으로 읽었다. 실제로 회귀 원인을 그쪽으로 의심하다 진짜 원인을 놓칠 뻔한 일이 있었다.
2960
+ 두 진입점을 표로 갈라 적고, 배포본에서 직접 세어 확인하는 법을 남겼다
2961
+ (`styles.css` 73회 · `tokens.css` 0회).
2962
+ - **덮어쓰기 명시도가 두 층이라는 것을 적었다(§3-1).** `:where()` 는 `:root`(토큰)에만
2963
+ 붙고 나머지 규칙은 `.scope .foo`(0,2,0) 로 나간다. "`:where` 라 명시도 0" 을 파일
2964
+ 전체로 읽어 컴포넌트 덮어쓰기 규칙을 지우려던 소비자가 있었다. 배포본 실측을 함께
2965
+ 적었다 — styles 는 `:where` 3회/자손결합자 59회, components 는 1회/2,341회.
2966
+ - **Tailwind v4 충돌을 적었다(§3-3).** `tokens.css` 의 다크 블록이
2967
+ `:root:not([data-theme=light]):not([data-theme=dark])` 로 명시도 (0,3,0) 이라, Tailwind 가
2968
+ `:root` (0,1,0) 에 두는 `--color-white`·`--color-black` 을 순서와 무관하게 이긴다.
2969
+ `data-theme` 속성이 없는 앱은 OS 가 다크이기만 하면 `bg-white` 가 거의 검정이 된다.
2970
+ 콘솔은 조용하고, 라이트 모드 개발 기기에서는 재현되지 않는다. 해결은 `<html data-theme>`
2971
+ 명시. 근본 원인은 이름 충돌이라 다음 메이저에서 다룬다.
2972
+
2973
+ ## 0.8.0
2974
+
2975
+ ### 바뀜 (파괴적)
2976
+
2977
+ - **패키지 이름을 `@ihab_giihan/core` → `@ihabdevteam/core` 로 바꾼다.** 옛 이름은 개인
2978
+ 계정(`giihan`)의 흔적이 남아 있었다. 이제 GitHub 조직·저장소·npm 스코프가 한 단어로
2979
+ 맞는다(github.com/ihabdevteam/ihab_core). 옛 이름은 npm 에서 deprecate 되며, 마지막
2980
+ 판은 `@ihab_giihan/core@0.7.0` 이다.
2981
+
2982
+ npm 은 이름 바꾸기를 지원하지 않는다 — 새 이름으로 새로 내고 옛 이름에는 표지판을
2983
+ 세우는 것이 전부다. 옛 판들은 계속 설치되므로 옮기는 쪽이 import 를 모두 바꿔야 한다.
2984
+ 지금 하는 까닭은 **소비자가 아직 하나뿐이고 그마저 npm 배포본으로 갈아타기 전**이라
2985
+ 값이 가장 쌀 때이기 때문이다.
2986
+
2987
+ `@ihab_core/core` 도 후보였지만 쓰지 않았다. npm 은 **스코프가 소유자·제품군이고
2988
+ 패키지가 산출물**인데, 그 조합은 말을 더듬고 형제가 생기면(`icons`·`tokens`) 소유자가
2989
+ 'core' 인 꼴이 된다. `ihab_core` 는 저장소 이름으로 이미 잘 쓰이고 있다.
2990
+
2991
+ - **CSS 스코프 클래스가 `.ihab-core-scope` → `.ihabdevteam-core-scope` 로 바뀐다.**
2992
+ 소비 앱이 `<body class="ihabdevteam-core-scope">` 로 고쳐야 한다. **안 고치면 콘솔
2993
+ 오류 없이 화면만 깨진다** — 토큰이 이 클래스에 정의되어 있어 `var(--…)` 가 전부 빈
2994
+ 값이 된다(CONSUMING.md 3절). 이 한 줄이 이번 판에서 가장 조심할 자리다.
2995
+
2996
+ - **배포 산출물 이름이 `ihab-core.*` → `ihabdevteam-core.*` 로 바뀐다.**
2997
+ `styles.css` · `tokens.css` 같은 exports 서브패스로 부르고 있었다면 바뀐 것이 없다.
2998
+ `dist/` 안을 직접 가리키고 있었다면 고쳐야 한다.
2999
+
3000
+ ## 0.4.3
3001
+
3002
+ ### 추가
3003
+
3004
+ - **`LandingHeader` 모바일 메뉴.** 좁은 화면(≤960px)에서 가운데 링크를 감추기만 하고
3005
+ 대체 수단이 없어서, 링크가 페이지 내 앵커가 아니라 사이트 경로일 때는 모바일에서
3006
+ 이동 자체가 막혔다. 햄버거(44x44)와 드로어(항목 48px)를 더한다. 넓은 화면에서는
3007
+ 햄버거가 숨고 드로어도 렌더되지 않는다.
3008
+ - **`LandingHeader` 에 `renderLink`.** 링크 렌더를 소비처가 가로챌 수 있다.
3009
+ Next.js 소비처가 `next/link` 를 넘겨 클라이언트 이동을 유지하려면 필요하다
3010
+ (없으면 매 이동이 전체 새로고침이 된다).
3011
+ - **`LandingHeader` 에 `menuLabel`.** 햄버거의 접근성 이름을 소비처가 번역해 넘긴다.
3012
+ 이 컴포넌트는 i18n 을 모른다는 원칙을 지키기 위한 것이다.
3013
+ - **`LandingHeaderLink.current`.** true 면 `aria-current="page"` 가 붙는다.
3014
+
3015
+ ### 수정
3016
+
3017
+ - **`Footer` 의 고객센터 연락처를 주입할 수 있다(`contact`).** 대표번호가
3018
+ `031-380-3724` 로 하드코딩돼 있어, 번호가 다른 소비처(아이해브 공식 웹은
3019
+ `031-496-8330`)가 잘못된 번호를 노출할 수밖에 없었다. 넘기지 않으면 기존 값을 쓴다.
3020
+ - **Pretendard 웹폰트가 실제로 적용된다.** `--font-family` 가 `'Pretendard'` 만
3021
+ 지정했는데 웹폰트 파일이 선언하는 패밀리 이름은 `'Pretendard Variable'` 이라,
3022
+ 폰트를 받아 두고도 시스템 폰트로 폴백하고 있었다. 개발 기기에 Pretendard 가
3023
+ 설치돼 있으면 정상으로 보여서 눈에 띄지 않았다. 모든 텍스트의 자형이 바뀐다.
3024
+ - **`Footer` 링크의 터치 타깃을 24px 로 넓힌다.** 텍스트 높이 그대로여서
3025
+ 좁은 화면에서 누르기 어려웠다.
3026
+ - **`SectionHeader` 의 어두운 톤 머리말이 색을 잃던 문제.** 존재하지 않는
3027
+ `--color-brand` 를 참조해 선언이 통째로 무효가 됐다(`tone` 기본값이 `dark` 라
3028
+ 기본 사용에서 바로 드러난다). `--color-main` 으로 고친다.
3029
+ - **`ReflexGame` 보기 영역의 타이포 클래스 오타**(`body-1bs` → `body-1b`).
3030
+ 정의되지 않은 클래스라 상속 폰트로 렌더되고 있었다.
3031
+
3032
+ ### 제거
3033
+
3034
+ - **`exports` 의 `"./logo"`.** 끝에 슬래시를 붙인 폴더 매핑은 Node 에서
3035
+ 폐기됐고(DEP0166) 실제로 `MODULE_NOT_FOUND` 였다 — 즉 처음부터 쓸 수 없었다.
3036
+ 파일 단위 `"./logo/*"` 는 그대로이므로 `@ihab_giihan/core/logo/Logo_IHAB_en.svg`
3037
+ 같은 기존 사용은 영향받지 않는다.
3038
+
3039
+ ### 기타
3040
+
3041
+ - 헤더 햄버거의 접근성 이름을 위한 로케일 키 `landing.nav.menuOpen` /
3042
+ `menuClose` 를 ko·en 에 추가했다.
3043
+ - `src` 안에 함께 두었던 테스트가 배포물로 따라 나가던 것을 제외한다(파일 4개).
3044
+
3045
+ ## 0.4.2
3046
+
3047
+ ### 추가
3048
+
3049
+ - **`SectionHeader`** — 섹션·페이지의 머리(머리말 · 제목 · 설명). 세 조각이 모두
3050
+ 선택이라 "머리말만", "제목+설명", "머리말+제목" 을 같은 컴포넌트로 쓴다.
3051
+ `level` 로 `<h1>`/`<h2>` 를 고르고, `tone` 으로 밝은 배경/어두운 배경을 뒤집는다.
3052
+ 제목은 본문 스케일(h1 = 32px)이 아니라 지면용 디스플레이 크기라 뷰포트에 따라
3053
+ 32~46px 로 커진다 — 소비처가 매번 clamp 를 다시 쓰던 부분이다.
3054
+ - **`CompanyMeta`** — 푸터의 사업자 정보 한 줄. 좁은 화면에서는 세로로 쌓이고
3055
+ 넓어지면 구분선과 함께 한 줄로 붙는다. 무엇을 넣을지는 소비처가 정한다.
3056
+
3057
+ ### 보안
3058
+
3059
+ - **`TextField type="password"` 가 HTML `value` 속성에 평문을 남기지 않습니다.**
3060
+ React 는 controlled input 의 값을 `node.defaultValue`(= HTML `value` 속성)에
3061
+ 동기화합니다. 일반 입력에서는 무해하지만 비밀번호에서는 그 속성이
3062
+ `outerHTML` 직렬화 경로 — 스크린샷·DOM 공유, 세션 리플레이, 브라우저 확장,
3063
+ 오류 리포터 — 를 타고 평문이 새는 통로가 됩니다. 실제로 어드민 로그인 화면을
3064
+ 공유했을 때 입력한 비밀번호가 마크업에 그대로 들어가 있었습니다.
3065
+
3066
+ 커밋 후에 속성을 지우는 방식은 React 의 `restoreControlledState` 가 다시 써서
3067
+ 듣지 않습니다. 그래서 비밀번호일 때는 `value`/`defaultValue` 를 아예 React 에
3068
+ 넘기지 않고, DOM `value` **프로퍼티**만 effect 에서 맞춥니다. 프로퍼티는
3069
+ 직렬화되지 않으므로 마크업에는 아무것도 남지 않고, 바깥에서 `value` 를 바꾸면
3070
+ 입력에 반영되는 controlled 의미는 그대로입니다.
3071
+
3072
+ 비밀번호가 아닌 필드의 동작은 바뀌지 않습니다.
3073
+ `test/TextFieldPassword.test.tsx` 가 평문 부재와 controlled 왕복을 검사합니다.
3074
+
3075
+ ### 수정
3076
+
3077
+ - **i18n 인스턴스 없이도 한국어가 나옵니다.** 컴포넌트들이 `useTranslation()` 을
3078
+ 쓰는데, i18next 를 초기화하지 않은 소비자에서는 `t('common.close')` 가 번역 대신
3079
+ 키 문자열을 그대로 돌려줬습니다. 그 값이 `aria-label` 로 들어가 스크린리더가
3080
+ "common.close" 를 읽는 식이었습니다. `core/i18n` 을 import 하면 해결되지만,
3081
+ 그러자고 로케일 리소스 전체(gzip 39.5 kB)를 받게 하는 건 과했습니다.
3082
+
3083
+ 이제 모든 `t()` 호출이 한국어 기본값을 함께 넘깁니다 — `t('common.close', '닫기')`.
3084
+ react-i18next 의 `notReadyT` 가 두 번째 인자가 문자열이면 그대로 반환하므로,
3085
+ 인스턴스 없이도 한국어가 나오고 초기화한 앱에서는 기존대로 번역이 우선합니다.
3086
+ 키가 로케일 파일에서 빠졌을 때의 안전망 역할도 겸합니다.
3087
+
3088
+ 적용 범위는 `components` / `app` / `hooks` / `utils` 의 1,558개 호출입니다.
3089
+ 보간(`{{}}`)이 들어간 값은 기본값으로 쓰면 자리표시자가 그대로 노출되므로
3090
+ 제외했습니다. 번들은 471.68 kB (gzip, +9.2 kB) 로 예산 800 kB 안에 있습니다.
3091
+
3092
+ 회귀 방지를 위해 `test/i18nFallback.test.tsx` 가 (가) 인스턴스 없이 렌더한
3093
+ 컴포넌트의 aria-label 이 한국어인지, (나) 소스에 기본값 없는 `t('key')` 가
3094
+ 남아 있지 않은지를 검사합니다.
3095
+
3096
+ ### 내부
3097
+
3098
+ - `utils/date.ts` 의 `TranslateFn` 과 `WordManipulationTrainingPagePopup` 의
3099
+ `getStepTitle` 이 좁게 선언한 `t` 타입을 쓰고 있어 기본값 인자를 받지 못했습니다.
3100
+ i18next 의 `TFunction` 을 타입으로만 가져와 맞췄습니다(런타임 비용 없음).
3101
+
3102
+ ## 0.4.1
3103
+
3104
+ npm 에는 0.3.0 까지만 배포되어 있어, 그 뒤 작업을 이 버전 하나로 합쳤습니다.
3105
+
3106
+ ### 추가
3107
+
3108
+ - **`Dialog`** — 공개 오버레이 프리미티브. 포커스 복귀·본문 스크롤 잠금·Esc 닫기·
3109
+ 배경 클릭 닫기를 포함하고 body 로 portal 합니다. `size="full"` 은 표면 없이
3110
+ 콘텐츠만 띄워 라이트박스로 씁니다. 그동안 공개된 오버레이가 도메인 전용
3111
+ (PagePopup, TrainingPagePopup)뿐이라 소비자가 매번 새로 만들었습니다.
3112
+ - **`Button` 에 `href`** — 주면 `<button>` 대신 `<a>` 로 렌더합니다. `target` / `rel`
3113
+ 도 받고 `target="_blank"` 면 `rel="noreferrer"` 를 채웁니다. `disabled` 면 링크를
3114
+ 걷어내고 `aria-disabled` 만 남깁니다. CTA 가 외부 링크인 소비자는 버튼 모양이
3115
+ 필요해도 링크 의미(새 창·우클릭·주소 복사)를 잃을 수 없었습니다.
3116
+ - **`Button` 에 `htmlType`** — DOM 의 button type(`button`/`submit`/`reset`).
3117
+ 기존 `type` 은 레이아웃 종류(`bar`/`dot`)라 이름이 겹쳐 별도 prop 으로 뒀습니다.
3118
+ - **`TextField` 에 `name`** — 없으면 비제어 폼에서 `new FormData(form)` 으로 값을
3119
+ 읽을 수 없어 소비자가 제어형으로 강제됐습니다.
3120
+ - **`EmptyState` 에 `compact`** — 표의 빈 행처럼 좁은 자리에서 여백을 줄입니다.
3121
+
3122
+ ### 변경
3123
+
3124
+ - **훅·브라우저 API 를 쓰는 모듈에 `'use client'` 지시어 추가** (225개).
3125
+ 지시어가 없어 React Server Components 환경(Next.js App Router 등)에서
3126
+ 서버 컴포넌트가 직접 import 하면 `useRef is not a function` 으로 프리렌더가
3127
+ 깨졌고, 소비자가 래퍼를 따로 만들어야 했습니다.
3128
+
3129
+ 순수 표현 컴포넌트(Badge, Board, PersonCard, PublicationCard, EmptyState,
3130
+ Divider, Icon, Section 등 87개 모듈)는 **일부러 지시어를 붙이지 않았습니다** —
3131
+ 서버에서 그대로 렌더되어야 RSC 소비자가 이득을 봅니다.
3132
+
3133
+ Vite SPA 인 speechear 본체에는 영향이 없습니다.
3134
+
3135
+ ## 0.3.0
3136
+
3137
+ ### Minor Changes
3138
+
3139
+ - 콘텐츠 사이트용 표현 컴포넌트 4종 추가
3140
+
3141
+ 마케팅·소개 페이지에서 반복되던 조각을 core로 옮겼습니다. 넷 다 상태를 갖지 않는
3142
+ 표현 컴포넌트라 서버 렌더링 환경(RSC 등)에서도 그대로 쓸 수 있습니다.
3143
+
3144
+ - `PersonCard` — 원형 사진 + 이름·직함 + 경력 목록. 운영진/연구진 소개용.
3145
+ `media` prop으로 프레임워크 이미지 컴포넌트를 주입할 수 있습니다.
3146
+ - `Timeline` — 그룹 제목 아래 라벨+내용 행을 쌓는 연혁·로드맵 목록.
3147
+ - `PublicationCard` — 우상단 장식(메달·리본) + 제목·분류·본문의 실적 카드.
3148
+ `badge` prop으로 이미지 컴포넌트를 주입할 수 있습니다.
3149
+ - `Board` — 게시판형 목록 표. 검색·페이지네이션은 `toolbar`로 주입하고
3150
+ 필터링된 `rows`를 넘기는 제어형입니다.
3151
+
3152
+ ## 0.2.0
3153
+
3154
+ ### Minor Changes
3155
+
3156
+ - 부작용 없는 `tokens.css` 엔트리 분리 + 타입 스케일·모션·시맨틱 토큰 추가
3157
+
3158
+ 토큰만 필요한 소비자(다른 프레임워크, Next.js 서버 컴포넌트 등)가 React 런타임과
3159
+ i18n 초기화 없이 디자인 토큰을 쓸 수 있습니다.
3160
+
3161
+ - `@ihab_giihan/core/tokens.css` — CSS 변수 전용 엔트리. `:root`에 그대로 선언되어
3162
+ 소비자 앱 전역에서 읽힙니다. 전체 번들 `styles.css`는 기존대로 `.ihab-core-scope`로 스코프.
3163
+ - 타입 스케일을 CSS 변수로 노출(`--text-h0-size` / `-line` / `-spacing` / `-weight` 등).
3164
+ 기존 `.h0`~`.h5`, `.body-*` 클래스가 이 변수를 참조하므로 겉보기 동작은 동일합니다.
3165
+ - 브랜드 오버라이드 훅 — `--color-main: var(--brand-main)`.
3166
+ - 모션(`--duration-*`, `--ease-*`), 브레이크포인트(`--bp-*`), `--container-page` 추가.
3167
+ - 역할 기반 시맨틱 색 별칭(`--color-text-primary`, `--color-surface`, `--color-border` 등).
3168
+
3169
+ ### Major Changes
3170
+
3171
+ - BREAKING: `Example/` 디렉토리를 `app/`으로 리네임 + 외부 소비/크로스플랫폼 빌드 정비
3172
+
3173
+ - 서브패스 import 경로 변경: `@ihab_giihan/core/Example/*` → `@ihab_giihan/core/app/*`
3174
+ - `app/*`는 speechear 전용 앱 페이지/데이터입니다. 범용 재사용은 `components`/`hooks`/`utils`를 사용하세요.
3175
+ - `@esbuild/linux-x64`(리눅스 전용 바이너리) 제거 → `esbuild` 메타 패키지로 교체.
3176
+ - `@testing-library/react` `^14` → `^16` (react 19 호환).
3177
+
3178
+ ## [Unreleased]
3179
+
3180
+ ### 훑개 고침 — 훑기가 **놀이터 설정을 눌러앉혔다** (판 없음)
3181
+
3182
+ 겉면 훑개는 단추를 다 눌러 본다. 그 안에 테마·글자 크기 시연 화면의 단추가 있어
3183
+ `applyTheme('auto')`·`applyFontScale('large3')` 가 불렸고, 그 값이 **localStorage 에
3184
+ 남았다.** 그래서 훑고 난 뒤 놀이터가 계속 그 설정으로 떴다 — 판이 OS 다크를 흉내
3185
+ 내고 있으면 코어 부분만 어두워져, 밝은 껍데기 안에 어두운 컴포넌트가 섞인 꼴이 된다.
3186
+
3187
+ **`git status` 로는 안 보이는 자국이다.** 소스를 되쓰는 저장 단추는 이미 걸러 두었고
3188
+ 그것은 `git status` 로 잡혔는데, 이쪽은 그 그물에 안 걸린다.
3189
+
3190
+ 훑기 앞뒤로 localStorage 를 통째로 갈무리했다가 되돌린다. 전체 훑기에만 걸었더니
3191
+ 한 화면짜리(`__overlayHere`)로 훑을 때 그대로 남는 것을 심어서 잡았다 — **들어오는
3192
+ 문이 여럿이면 문마다 걸어야 한다.**
3193
+
3194
+ ### 그물 — 스코프 프리픽스가 죽여 놓은 규칙이 더 있는지 훑었다 (판 없음)
3195
+
3196
+ 0.59.0 에서 `applyFontScale()` 이 통째로 죽어 있던 것을 찾은 뒤, **같은 함정이 또
3197
+ 있는지** `src/**/*.css` 의 선택자를 빌드가 쓰는 그 줄에 통과시켜 봤다.
3198
+
3199
+ **새로 죽은 것은 없다.** 남은 셋은 다 아는 것이다 — `body` → 스코프 원소(설계대로),
3200
+ 그리고 `@media (prefers-color-scheme: dark)` 안의 `:root:not([data-theme=…])` 둘
3201
+ (`tokens.css`·`PassCard.css`). 뒤의 둘은 **일부러 후손 꼴로 둔 것**이다: 여기서
3202
+ 고치면 OS 를 다크로 둔 모든 사용자의 화면이 한꺼번에 뒤집힌다. 사람이 정할 몫이라
3203
+ 목록에만 남긴다.
3204
+
3205
+ 훑기를 `test/scopePrefixNoDeadRules.test.ts` 로 세웠다. **새 선택자를 CSS 에 적었을
3206
+ 때 그것이 죽는지를 그때 알려 준다** — `[data-density="compact"]` 를 심어 확인했다.
3207
+
3208
+ 그물을 짜다 또 한 번 헛통과했다. `body` 검사를 "스코프 글자를 **포함하는가**" 로
3209
+ 물었더니, 맞바꿈을 빼서 `.scope body` 가 돼도 통과했다. **무엇이 되는지**를 정확히
3210
+ 묻는다.
3211
+
3212
+ ### 검수 도구 — `scripts/text-spacing.browser.js` (판 없음)
3213
+
3214
+ 글자 간격을 규격대로 늘려도 내용이 안 잘리는지 훑는다(WCAG 1.4.12 Text Spacing).
3215
+ 줄 높이 1.5 · 자간 0.12em · 어간 0.16em · 문단 2em 을 덮어쓰고, **늘리기 전/후를
3216
+ 견주어 새로 잘린 것만** 센다.
3217
+
3218
+ 141개 화면·감춘 상자 6,535개를 재어 열한 곳이 나왔다. 대부분은 놀이터 시연 상자와
3219
+ 일부러 줄인 미리보기였고, **코어에서 제 글자를 자르는 자리는 `ActionCard` 하나**다.
3220
+ 그것은 고치면 카드 모양이 바뀌는 **설계 결정**이라 사용자 몫으로 남겼다(REVIEW.md).
3221
+
3222
+ ### 재고 0 이었던 갈래 둘 — 그물이 참말을 하고 있었다 (판 없음)
3223
+
3224
+ **모달 안에서 Tab 이 갇히는가** — 놀이터의 모달 아홉 곳을 열어 진짜 Tab 키로
3225
+ 칸수+2 번씩 눌렀다. 하나도 안 샜다(가둠 없는 대조군은 두 번 만에 샜다).
3226
+ **겹친 겉면에서 Esc 가 맨 위 하나만 닫는가** — 팝업 안에서 드롭다운을 열고 Esc:
3227
+ 드롭다운만 닫히고 팝업은 남는다. 마우스로 연 경우도 같다(진짜 클릭으로 확인).
3228
+
3229
+ 둘 다 이미 `useFocusTrap`·`dialogFocusTrap`·`escStack`·`dialogEscStack` 이
3230
+ 지키고 있었다. **그 그물들이 참말을 한다는 것을 브라우저로 확인한 것**이 이번의
3231
+ 수확이다.
3232
+
3233
+ - Initial extraction from main project
3234
+ - Rename `Btn` -> `Button` (component and exports)
3235
+ - Update imports across repository to use `Button`
3236
+ - Prepare package for release: build artifacts and version bump to 0.1.1
3237
+ - Rename package from `@ihab/ui` to `@ihab/core`
3238
+ - Rename build outputs from `ihab-ui.*` to `ihab-core.*`
3239
+ - Rename package directory from `packages/ui` to `packages/core`
3240
+ - Add subpath exports for `components`, `hooks`, `utils`, and `styles.css`
3241
+ - Scope package CSS under `.ihab-core-scope` to reduce collisions in host projects
3242
+ - Add `CoreScope` wrapper and scoped foundation tokens for external integration
3243
+ - Add export audit script and bundle analysis script for publish-time verification
3244
+ - Add `typesVersions` and explicit `types` entries for `./hooks` and `./utils`
3245
+ - Add `setAssetBaseUrl()`, `getAssetBaseUrl()`, and `resolveAssetUrl()` for static asset hosting control
3246
+ - Export `TrainingPagePopupBottom` and `TrainingPagePopupTitle` as documented fallback symbols
3247
+ - Harden `speak()` and `stopSpeaking()` for SSR / non-browser execution
3248
+
3249
+ ### Migration Guide
3250
+
3251
+ - Prefer named import or namespace import over relying on `default`:
3252
+ - `import { Button } from '@ihab_giihan/core'`
3253
+ - `import * as Core from '@ihab_giihan/core'`
3254
+ - If your host app serves package images from a CDN or subpath, call `setAssetBaseUrl()` once during app bootstrap.
3255
+ - If you previously reached into internal popup files, switch to public exports `TrainingPagePopupBottom` and `TrainingPagePopupTitle`.
3256
+
3257
+ ### Version Policy
3258
+
3259
+ - Patch: 문서/빌드/테스트/비파괴적 버그 수정
3260
+ - Minor: 새 export, 새 컴포넌트, 옵트인 기능 추가
3261
+ - Major: 기존 export 제거, import 경로 변경, 런타임 동작 변화
3262
+