@hjmds/design-contracts 1.27.2 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/activity-chip.d.ts +1 -1
  2. package/dist/activity-chip.js +1 -1
  3. package/dist/activity-chip.js.map +1 -1
  4. package/dist/auth-screen.d.ts +77 -5
  5. package/dist/auth-screen.d.ts.map +1 -1
  6. package/dist/auth-screen.js +63 -2
  7. package/dist/auth-screen.js.map +1 -1
  8. package/dist/base-recipes.d.ts +1 -1
  9. package/dist/base-recipes.js +1 -1
  10. package/dist/base-recipes.js.map +1 -1
  11. package/dist/behaviors.d.ts +1 -1
  12. package/dist/breadcrumb.d.ts +1 -1
  13. package/dist/breadcrumb.js +1 -1
  14. package/dist/breadcrumb.js.map +1 -1
  15. package/dist/calendar.d.ts +1 -1
  16. package/dist/calendar.js +2 -2
  17. package/dist/calendar.js.map +1 -1
  18. package/dist/catalog.d.ts +131 -87
  19. package/dist/catalog.d.ts.map +1 -1
  20. package/dist/component-contracts.d.ts +1 -1
  21. package/dist/component-contracts.js +1 -1
  22. package/dist/component-contracts.js.map +1 -1
  23. package/dist/component-recipes.d.ts +85 -57
  24. package/dist/component-recipes.d.ts.map +1 -1
  25. package/dist/component-recipes.js +98 -60
  26. package/dist/component-recipes.js.map +1 -1
  27. package/dist/conversation-row.d.ts +2 -2
  28. package/dist/conversation-row.d.ts.map +1 -1
  29. package/dist/conversation-row.js +6 -4
  30. package/dist/conversation-row.js.map +1 -1
  31. package/dist/counter-badge-recipe.d.ts +1 -1
  32. package/dist/counter-badge-recipe.js +1 -1
  33. package/dist/counter-badge-recipe.js.map +1 -1
  34. package/dist/data-table.d.ts +4 -4
  35. package/dist/data-table.js +4 -4
  36. package/dist/data-table.js.map +1 -1
  37. package/dist/date-picker.d.ts +1 -1
  38. package/dist/design-profile.d.ts.map +1 -1
  39. package/dist/design-profile.js +19 -16
  40. package/dist/design-profile.js.map +1 -1
  41. package/dist/file-picker.d.ts +1 -1
  42. package/dist/file-picker.js +1 -1
  43. package/dist/file-picker.js.map +1 -1
  44. package/dist/foundations.d.ts +18 -14
  45. package/dist/foundations.d.ts.map +1 -1
  46. package/dist/foundations.js +18 -13
  47. package/dist/foundations.js.map +1 -1
  48. package/dist/heading.d.ts +13 -13
  49. package/dist/heading.js.map +1 -1
  50. package/dist/number-field.d.ts +1 -1
  51. package/dist/otp-field.d.ts +1 -1
  52. package/dist/password-field.d.ts +1 -1
  53. package/dist/result.d.ts +1 -1
  54. package/dist/result.js +2 -2
  55. package/dist/result.js.map +1 -1
  56. package/dist/screen-patterns.d.ts +36 -0
  57. package/dist/screen-patterns.d.ts.map +1 -1
  58. package/dist/screen-patterns.js +20 -0
  59. package/dist/screen-patterns.js.map +1 -1
  60. package/dist/semantic-colors.d.ts +10 -0
  61. package/dist/semantic-colors.d.ts.map +1 -1
  62. package/dist/semantic-colors.js +6 -0
  63. package/dist/semantic-colors.js.map +1 -1
  64. package/dist/sheet.d.ts +7 -4
  65. package/dist/sheet.d.ts.map +1 -1
  66. package/dist/sheet.js +8 -5
  67. package/dist/sheet.js.map +1 -1
  68. package/dist/side-panel.d.ts +1 -1
  69. package/dist/side-panel.js +2 -2
  70. package/dist/side-panel.js.map +1 -1
  71. package/dist/steps.d.ts +1 -1
  72. package/dist/steps.js +1 -1
  73. package/dist/steps.js.map +1 -1
  74. package/dist/tag.d.ts +1 -1
  75. package/dist/tag.js +1 -1
  76. package/dist/tag.js.map +1 -1
  77. package/dist/timeline.d.ts +1 -1
  78. package/dist/timeline.js +1 -1
  79. package/dist/timeline.js.map +1 -1
  80. package/dist/top.d.ts +4 -4
  81. package/dist/top.d.ts.map +1 -1
  82. package/dist/top.js +3 -1
  83. package/dist/top.js.map +1 -1
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/dist/version.js.map +1 -1
  87. package/docs/activity-chip.md +5 -1
  88. package/docs/auth-screen.md +2 -0
  89. package/docs/design-profile.md +14 -10
  90. package/docs/generated/component-maturity.md +1 -1
  91. package/docs/generated/renderer-evidence.json +9 -5
  92. package/docs/generated/renderer-evidence.md +1 -1
  93. package/docs/generated/showcase-manifest.json +1 -1
  94. package/docs/heading.md +14 -9
  95. package/docs/sheet.md +2 -0
  96. package/docs/top.md +4 -3
  97. package/docs/usage/README.md +1 -1
  98. package/docs/usage/SELECTION.md +1 -0
  99. package/docs/usage/components/auth-screen-layout.md +44 -11
  100. package/docs/usage/components/bottom-cta.md +6 -2
  101. package/docs/usage/components/breadcrumb.md +5 -1
  102. package/docs/usage/components/chat-screen.md +1 -0
  103. package/docs/usage/components/checkbox-group.md +5 -1
  104. package/docs/usage/components/collapsible.md +1 -0
  105. package/docs/usage/components/comment-thread-screen.md +2 -0
  106. package/docs/usage/components/dialog.md +1 -0
  107. package/docs/usage/components/editor-screen.md +1 -0
  108. package/docs/usage/components/effect-surface.md +2 -2
  109. package/docs/usage/components/field.md +1 -0
  110. package/docs/usage/components/heading.md +14 -12
  111. package/docs/usage/components/list-detail-screen.md +1 -0
  112. package/docs/usage/components/list-row.md +7 -3
  113. package/docs/usage/components/list.md +25 -4
  114. package/docs/usage/components/notification-item.md +5 -1
  115. package/docs/usage/components/onboarding-screen.md +1 -0
  116. package/docs/usage/components/photo-source-sheet.md +1 -1
  117. package/docs/usage/components/screen-layout.md +28 -3
  118. package/docs/usage/components/search-field.md +1 -0
  119. package/docs/usage/components/search-screen.md +4 -2
  120. package/docs/usage/components/section.md +5 -1
  121. package/docs/usage/components/segmented-control.md +1 -1
  122. package/docs/usage/components/sheet.md +18 -13
  123. package/docs/usage/components/skeleton.md +42 -15
  124. package/docs/usage/components/statistic.md +5 -1
  125. package/docs/usage/components/switch.md +3 -2
  126. package/docs/usage/components/tag.md +5 -1
  127. package/docs/usage/components/text-area.md +1 -0
  128. package/docs/usage/components/text.md +6 -4
  129. package/docs/usage/components/top.md +1 -1
  130. package/docs/usage/compositions/adaptive-content.md +5 -1
  131. package/docs/usage/compositions/compound-controls.md +2 -1
  132. package/docs/usage/compositions/design-profile-comparison.md +1 -1
  133. package/docs/usage/compositions/family-drawer.md +5 -3
  134. package/docs/usage/compositions/input-sheet.md +1 -1
  135. package/docs/usage/compositions/interaction-flow-apply.md +3 -3
  136. package/docs/usage/compositions/photo-source.md +3 -3
  137. package/docs/usage/compositions/selection-motion.md +5 -1
  138. package/docs/usage/compositions/visual-foundations.md +5 -4
  139. package/docs/usage/screens/common-login.md +9 -7
  140. package/docs/usage/screens/landing.md +15 -14
  141. package/docs/usage/screens/notification-settings.md +4 -2
  142. package/docs/usage/tokens/typography.md +21 -13
  143. package/package.json +1 -1
@@ -86,7 +86,7 @@ Native 원형 leading의 `full`은 Provider token에서 읽지만 고정 원형
86
86
  | --- | --- | --- |
87
87
  | 크기 | 행 표면(누름·hover·선택 배경, 포커스 링, 누를 수 있는 영역)은 부모 콘텐츠 폭보다 양쪽으로 `listRowRecipe.surfaceBleed`(= density `paddingHorizontal`, `spacing.xs` 8)씩 넓다(Web `inline-size: calc(100% + 16)` + `margin-inline: -8`, Native `marginHorizontal: -8`). 글자·leading·trailing은 부모 콘텐츠 가장자리에 놓인다. leading 프레임은 40×40(`leadingSize`), `circle`이면 `radius.full`. trailing 아이콘은 `glyph.sm` 20. 최소 높이는 아래 density 표 | `design-contracts/src/component-recipes.ts`(`listRowRecipe`), `design-contracts/src/foundations.ts`(`layout.rowHeight`) |
88
88
  | 간격 | leading·content·trailing 사이 `spacing.sm` 12(`listRowRecipe.gap`). 행 안 좌우 여백 8은 표면이 바깥으로 넓어진 만큼이라 글자는 부모 콘텐츠 가장자리(기본 화면이면 여백 선 16)에서 시작한다. 위아래 여백은 아래 density 표. Native의 투명 선택 테두리 1은 이 여백 안에 포함된다(테두리 + padding = 8). 제품은 행에 margin을 주지 않는다 | `design-contracts/src/component-recipes.ts`(`listRowRecipe`), `react/src/styles.css`(`.hjm-list-row`) |
89
- | 순서·정렬 | leading → content(제목·설명) → trailing. trailing 내용(아이콘·Switch·문구)은 행의 세로 가운데에 둔다(Web `.hjm-list-row__trailing` flex 가운데, Native 행 가운데 정렬 View). `trailingAction`(두 플랫폼)은 행 명령 바깥 끝에 `listRowRecipe.gap` 12 떨어져 놓이고, 명령과 함께 하나의 표면(선택 배경·테두리)을 쓴다. 행동 상자의 끝은 부모 콘텐츠 가장자리에 맞으므로 ghost IconButton에 `edge="end"`를 주면 glyph 칸 끝이 여백 선 16에 온다. 행 사이 구분선·바깥 둥근 배경은 [List](list.md)가 그린다 | `react-native/src/data-display.tsx`(ListRow), `react/src/styles.css`(`.hjm-list-row__trailing`) |
89
+ | 순서·정렬 | leading → content(제목·설명) → trailing. trailing 내용(아이콘·Switch·문구)은 행의 세로 가운데에 둔다(Web `.hjm-list-row__trailing` flex 가운데, Native 행 가운데 정렬 View). `trailingAction`(두 플랫폼)은 행 명령 바깥 끝에 `listRowRecipe.gap` 12 떨어져 놓이고, 명령과 함께 하나의 표면(선택 배경·테두리)을 쓴다. 행동 상자의 끝은 부모 콘텐츠 가장자리에 맞으므로 ghost IconButton에 `edge="end"`를 주면 glyph 칸 끝이 여백 선 16에 온다. 행 사이 구분선·바깥 둥근 배경은 [List](list.md)가 그린다. 행은 leading 여부를 List에 알려(Web 루트 `data-leading`, Native List 항목 context) 자기 아래 `indented` 구분선이 글자 열(leading 있으면 52, 없으면 0)에서 시작하게 한다 | `react-native/src/data-display.tsx`(ListRow), `react/src/styles.css`(`.hjm-list-row__trailing`) |
90
90
  | 고정·스크롤 | — | — |
91
91
  | 좁은 폭·큰 글자 | 긴 제목·설명은 줄바꿈되고(`overflow-wrap: anywhere`) 행 높이가 늘어난다. 큰 글자에서도 자르지 않는다 | `react/src/styles.css`(`.hjm-list-row`) |
92
92
 
@@ -100,7 +100,7 @@ Native 원형 leading의 `full`은 Provider token에서 읽지만 고정 원형
100
100
  ```text
101
101
  화면 끝 ↓ 여백 선 16 ↓ ↓ 여백 선 16
102
102
  ┌─8─┬──────────────────────────────────────────┬─8─┐ ← 행 표면(누름·선택 배경), 8씩 바깥으로
103
- │ │[40×40]│12│ 제목(bodyLarge bold) │12│ 값 ›│ │
103
+ │ │[40×40]│12│ 제목(bodyLarge 500) │12│ 값 ›│ │
104
104
  │ │leading│ │ 설명(body, secondary) │ │trail│ │
105
105
  └───┴──────────────────────────────────────────┴───┘
106
106
  섹션 제목·결과 개수(16)와 행 글자·leading(16)이 같은 선에서 시작한다
@@ -113,7 +113,7 @@ Native 원형 leading의 `full`은 Provider token에서 읽지만 고정 원형
113
113
 
114
114
  - 제목·설명은 i18n 키로 넣는다. leading의 사진·아이콘은 장식으로 두고 의미는 제목이 말한다.
115
115
  - 행 안에 다른 버튼을 넣지 않는다. 별도 target은 `trailingAction`에 둔다(행 명령 옆에 따로 그린다).
116
- - 행은 부모가 좌우로 8 이상의 여백을 가진 곳에 둔다(화면 본문 16, Card·Sheet·SidePanel 본문, `grouped` List). 여백이 0인 상자(예: 여백 없는 분할 패널, 제품의 꽉 찬 div)에 바로 두면 표면이 잘리고 Web은 가로 스크롤이 생긴다. 그 상자에 콘텐츠 여백을 준다. `ScreenLayout contentInset="body-edge"` 본문은 자기 여백 16을 가진 전체 폭 행(ConversationRow·NotificationRow) 전용이다. ListRow·NotificationItem은 `default` 본문에 둔다(그래야 글자가 여백 선 16에 온다).
116
+ - 행은 부모가 좌우로 8 이상의 여백을 가진 곳에 둔다(화면 본문 16, Card·Sheet·SidePanel 본문, `grouped` List). 여백이 0인 상자(예: 여백 없는 분할 패널, 제품의 꽉 찬 div)에 바로 두면 표면이 잘리고 Web은 가로 스크롤이 생긴다. 그 상자에 콘텐츠 여백을 준다. HJM의 스크롤 상자는 이 여유를 스스로 만든다: VirtualList는 바깥으로 8 넓히고 안쪽 8을 두며, 스크롤 Sheet 본문은 시트 여백 끝까지 넓고 여백을 안쪽에 둔다(내부 `listRowScrollRoom`). 콘텐츠 위치는 그대로다. 화면을 바깥 Container 여백 + `contentInset="none"`으로 감싸는 구성은 지원하지 않는다([ScreenLayout](screen-layout.md#꼭-지킬-것)). `ScreenLayout contentInset="body-edge"` 본문은 자기 여백 16을 가진 전체 폭 행(ConversationRow·NotificationRow) 전용이다. ListRow·NotificationItem은 `default` 본문에 둔다(그래야 글자가 여백 선 16에 온다).
117
117
  - 배치는 `layoutStyle`로 한다. 높이·여백·배경을 덮지 않는다. 밀도는 `density`로 바꾼다.
118
118
  - 아바타 프레임을 제품이 다시 그리지 않는다. `leadingShape`를 쓴다.
119
119
 
@@ -148,5 +148,9 @@ Web도 Native와 같이 `trailingAction`을 지원한다. 이 슬롯은 행의 l
148
148
 
149
149
  ## 근거
150
150
 
151
+ 2026-10-17 사용자 결정("굵기 좀더 줄여"): 모든 글자 굵기를 한 단계 낮췄다. 큰 숫자·선택 상태 600(큰 제목 단계는 같은 날 폐지), 제목·강조 500, label 500, 본문 400이며 정본은 [타이포그래피](../tokens/typography.md)다.
152
+
153
+ - 스크롤 상자의 표면 여유: 2026-10-17 1.27.2 채택 점검. Native 스크롤 Sheet는 여백 20이 ScrollView 바깥에 있어 행 표면이 시트 콘텐츠 끝(21pt)에서 잘렸다(3x 측정: 선택 테두리 위 선 21–381, 옆 선 없음 → 수정 후 옆 선 13·끝 13, 제목 위치 그대로). VirtualList와 같은 규칙이라 내부 helper 하나로 묶었다.
151
154
  - trailing 세로 가운데: 2026-10-17 정렬 실측(Web 402). trailing span이 block이라 안의 inline svg가 글자 기준선에 놓여 행 가운데보다 2~2.5px 위였다(설정 334/331.5, 컬렉션 184/181.5). Native는 행 가운데(388/388)였다. trailing을 flex 가운데 상자로 바꾼 뒤 Web도 334/334, 184/184로 측정됐다.
152
155
  - 글자를 여백 선에, 표면은 바깥으로: 2026-10-17 사용자 결정. 행이 누름 배경용 좌우 여백 8을 콘텐츠 안에 두어 기본 화면에서 행 글자가 16 + 8 = 24에서 시작했고, 같은 화면의 섹션 제목·검색 결과 머리("결과 N개")·설정 묶음 제목은 16에서 시작했다(Web 402 실측: 검색 결과 행 24 / 머리 16, 설정 leading 24 / 묶음 제목 16, 알림 leading 24 / "오늘" 16. Native 3x 화면 잉크: 결과 행 제목 25 / 개수 16.7 — Native는 투명 선택 테두리 1까지 더해졌다). 표면을 바깥으로 8 넓히는 방식으로 바꾼 뒤 Web 402·1280, LTR·RTL 모두 행 글자·leading 시작과 trailing 끝이 16(1280은 화면 열 기준 296)으로, Native는 결과 행 제목 잉크 16, 설정 leading 아이콘 잉크 36 → 27, 알림 42.3 → 33(프레임 16 + 아이콘 안쪽 여백)으로 측정됐다. 구분선 시작도 글자 위치(52)로 옮겼다.
156
+ - 구분선이 행의 글자 열을 따름: 2026-10-17 사용자 보고(leading 없는 행에서 구분선이 중간에서 잘림). 위 52는 leading 있는 행의 글자 위치일 뿐이라 leading 없는 행(글자 16)에서는 구분선이 68에서 시작했다. 행이 leading 여부를 List에 알리도록 바꿔 구분선 시작이 각 행의 글자 열과 같아졌다([List 근거](list.md#근거)).
@@ -73,7 +73,7 @@ import { Text } from "@hjmds/react-native/primitives";
73
73
  | prop | 값 | 기본값 | 설명 |
74
74
  | --- | --- | --- | --- |
75
75
  | `label` | 현지화 문자열(필수) | — | 목록의 접근성 이름. 비면 `TypeError` |
76
- | `separator` | `indented` · `full` · `none` | `indented` | `indented`는 leading 슬롯 폭만큼 들여씀 |
76
+ | `separator` | `indented` · `full` · `none` | `indented` | `indented`는 위 행의 글자 열에서 시작(leading이 있으면 52, 없으면 0) |
77
77
  | `appearance` | `plain` · `grouped` | `plain` | `grouped`는 배경과 둥근 모서리로 한 덩어리 |
78
78
  | `layoutStyle` | 배치 key만 | — | 목록 루트 배치. Native `style`은 제거된 속성 — `layoutStyle`, 모양은 `appearance`·`separator` |
79
79
  | TaskList `items` | `readonly TaskItem[]` — `{ id, label, completed, description?, disabled? }` | — | `id`·`label`이 비거나 `id`가 중복되거나 `completed`가 boolean이 아니면 `TypeError` |
@@ -93,7 +93,7 @@ Native `grouped` 프레임은 Provider의 `tokens.radius.lg`를 읽는다. plain
93
93
  | 항목 | 값 | 근거 |
94
94
  | --- | --- | --- |
95
95
  | 크기 | 화면 본문 폭을 채우는 세로 묶음이다. `plain`은 자르지 않으므로 행 표면이 부모 여백 쪽으로 8씩 넓어진다(행 글자는 목록 콘텐츠 가장자리). `grouped`는 배경 `--hjm-color-bg`와 모서리 `radius.lg` 16으로 한 덩어리가 되고 그 모양으로 자르므로, 좌우 안쪽 여백 `listRecipe.grouped.paddingInline`(= `listRowRecipe.surfaceBleed` 8)을 두어 행 표면이 잘리지 않고 행 글자는 이전처럼 덩어리 끝에서 8 안쪽에 온다 | `design-contracts/src/component-recipes.ts`(`listRecipe`), `react/src/styles.css`(`.hjm-list`), `react-native/src/data-display.tsx`(List) |
96
- | 간격 | 행 사이 간격은 없고 구분선 1px(`--hjm-color-border`)만 들어간다. 구분선 위치는 목록 콘텐츠 가장자리(= 행 글자 시작) 기준이다: `indented`는 시작 52(leading 40 + `spacing.sm` 12, 글자 시작과 같은 위치), 끝 −8(행 표면 끝까지). `full`은 시작·끝 모두 −8로 행 표면 폭과 같다(`listRecipe.separators`, 두 플랫폼, Web `--hjm-list-separator-*`). 덩어리 바깥 여백·섹션 사이 간격은 감싸는 Stack이 준다(페이지 좌우 여백은 `layout.pagePadding`, 섹션 간격은 `layout.sectionGap` = `spacing.xl` 24) | `design-contracts/src/component-recipes.ts`(`listRecipe`), `design-contracts/src/foundations.ts`(`layout`), `react/src/styles.css`(`.hjm-list`), `react-native/src/data-display.tsx`(List) |
96
+ | 간격 | 행 사이 간격은 없고 구분선 1px(`--hjm-color-border`)만 들어간다. 구분선 위치는 목록 콘텐츠 가장자리(= leading 없는 행의 글자 시작) 기준이고, 각 구분선은 바로 위 행에 속한다(iOS 셀 구분선과 같다). `indented`는 위 행의 글자 열에서 시작한다: leading이 있으면 `leadingInsetStart` 52(leading 40 + `spacing.sm` 12), 없으면 `insetStart` 0. 끝은 행의 trailing 끝과 같은 콘텐츠 가장자리 0이다. `full`은 위 행과 무관하게 시작·끝 모두 −8로 행 표면 폭과 같다(`listRecipe.separators`, 두 플랫폼, Web `--hjm-list-separator-*`). 위 행의 leading 여부는 ListRow가 알린다: Web은 행 루트의 `data-leading`, Native는 List 항목 context. 덩어리 바깥 여백·섹션 사이 간격은 감싸는 Stack이 준다(페이지 좌우 여백은 `layout.pagePadding`, 섹션 간격은 `layout.sectionGap` = `spacing.xl` 24) | `design-contracts/src/component-recipes.ts`(`listRecipe`), `design-contracts/src/foundations.ts`(`layout`), `react/src/styles.css`(`.hjm-list`), `react-native/src/data-display.tsx`(List) |
97
97
  | 순서·정렬 | 다음 페이지 요청은 목록 바로 아래에 [LoadMore](load-more.md)를 둔다 | — |
98
98
  | 고정·스크롤 | List 자체는 스크롤하지 않는다. 화면 스크롤 안에 둔다 | `react-native/src/data-display.tsx`(List) |
99
99
  | 좁은 폭·큰 글자 | — | — |
@@ -101,7 +101,7 @@ Native `grouped` 프레임은 Provider의 `tokens.radius.lg`를 읽는다. plain
101
101
  ```text
102
102
  ┌─ List grouped (radius.lg 16, --hjm-color-bg) ─────────┐
103
103
  │ ListRow │
104
- │ ────────────────────────────────────────── │ ← indented 구분선(시작 들여씀)
104
+ │ ────────────────────────────────────────── │ ← indented 구분선(위 행에 leading이 있으면 글자 열 52, 없으면 0)
105
105
  │ ListRow │
106
106
  │ ────────────────────────────────────────── │
107
107
  │ ListRow │
@@ -157,6 +157,7 @@ import { CollectionRail } from "@hjmds/react/collection-rail";
157
157
  | `defaultStartKey` | item ID | 첫 항목 | 초기 위치만. 선택/상세 값 아님 |
158
158
  | `onStartKeyChange` | `(key: string|null) => void` | 생략 | 앞쪽 항목 변화 관찰; 선택 확정 callback 아님 |
159
159
  | `emptyContent` | ReactNode | 생략 | 제품의 빈 목록 내용 |
160
+ | `bleed` | boolean | `false` | 1.28.1. 기본 inset ScreenLayout 본문 안에서 화면 좌우 끝까지 스크롤하고 첫·끝 항목은 본문 여백(기본 `spacing.md` 16, ChatScreen 10)에 맞춘다. viewport가 여백만큼 밖으로 나가고 같은 양을 안쪽 padding으로 되돌린다(ListRow bleed와 같은 규칙, Web `--hjm-screen-body-inline`, Native 본문 inset context). 본문 바로 아래 섹션으로 둔다 — Card·Surface 안, `body-edge`/`none` 본문, Sheet·Dialog 안에서는 넘을 여백이 0이라 움직이지 않는다 |
160
161
  | `layoutStyle` | 공개 배치 key | 생략 | 루트 배치. 카드 시각 표현은 Card/Provider |
161
162
 
162
163
  #### 배치
@@ -169,6 +170,25 @@ import { CollectionRail } from "@hjmds/react/collection-rail";
169
170
  | 고정·스크롤 | viewport만 실제 가로 scroll, controls는 밖. 끝에서 멈추고 자동 이동 없음 | 공통 maxOffset·target resolver |
170
171
  | 좁은 폭·큰 글자 | 기본 글자의 좁은 폭에서 실제 측정으로 카드 폭을 줄임. 최대 글자 조건은 제외 | 2026-10-07 사용자 범위, root AGENTS.md |
171
172
 
173
+ #### 화면 끝까지 닿는 가로 목록(`bleed`)
174
+
175
+ ```text
176
+ │←16→│ 제목(본문 여백) │
177
+ │[카드1][카드2][카드3][카… ← 스크롤하면 화면 끝까지, 처음·끝 카드는 16 여백선
178
+ ```
179
+
180
+ 한 섹션만 화면 끝까지 스크롤하려고 화면 전체를 `contentInset="body-edge"`로 바꾸고 섹션마다 `Container gutter`를 다시 주지 않는다(spint 홈 "더 보기" 2026-10-17). 기본 inset을 두고 그 섹션의 CollectionRail에 `bleed`를 준다.
181
+ `scroll="content"`에서 제품 목록(FlatList·스크롤 `div`)이 본문을 스크롤하면 그 viewport가 여백 안에서 rail을 자른다. 그 목록에 `useScreenBodyScrollRoom()`(`/screens`, Web·Native 같은 이름)을 준다 — Native `style`·`contentContainerStyle`, Web `style`. viewport는 화면 끝까지, 내용은 여백선에 그대로 남고, 안의 `bleed` rail이 끝까지 스크롤한다.
182
+
183
+ ```tsx
184
+ // Native: spint 홈처럼 FlatList가 본문을 스크롤하는 화면
185
+ const room = useScreenBodyScrollRoom();
186
+ <ScreenLayout title={t("hot.title")} scroll="content">
187
+ <FlatList style={[{ flex: 1 }, room.style]} contentContainerStyle={room.contentContainerStyle} data={rows} renderItem={renderCard}
188
+ ListHeaderComponent={<CollectionRail bleed label={t("more.title")} items={more} labels={railLabels} renderItem={renderTile} />} />
189
+ </ScreenLayout>
190
+ ```
191
+
172
192
  #### 꼭 지킬 것
173
193
 
174
194
  같은 항목에 안정 ID를 쓰고 테마/폭을 subtree key로 두지 않는다. 카드 전체를 버튼으로 만들어 입력/상세 버튼을 중첩하지 않는다.
@@ -197,7 +217,7 @@ import { CollectionRail } from "@hjmds/react/collection-rail";
197
217
  | 항목 | Web | Native |
198
218
  | --- | --- | --- |
199
219
  | 구조 | `role="list"` + 자식마다 `role="listitem"` | `accessibilityRole="list"` |
200
- | 구분선 | CSS(`data-separator`) | 행 사이 1px `View` |
220
+ | 구분선 | CSS(`data-separator`, 다음 항목 `::before`, 위 행 `data-leading`은 `:has()`로 읽음) | 행 사이 1px `View`(위 행 ListRow가 context로 leading 여부를 알림) |
201
221
  | 배치 | `layoutStyle`(+ `className`) | `layoutStyle`(`style`은 제거된 속성) |
202
222
 
203
223
  2026-10-07 Utilverse 항목 삭제 채택을 위해 `renderItemAction`을 추가했다. 체크와 삭제의 초점·누름을 분리하고 큰 글자 라벨 폭을 보존하도록 행동을 다음 줄에 둔다. `renderCollection`의 `renderItem`에도 포함된다. 삭제 저장·실패·되돌리기는 제품이 처리한다.
@@ -205,4 +225,5 @@ import { CollectionRail } from "@hjmds/react/collection-rail";
205
225
  항목 삭제 뒤에는 제품이 다음 항목의 독립 행동(없으면 이전 항목, 목록이 비면 후속 행동)으로 초점을 복구한다. Web Showcase는 ref와 커밋 후 focus 예시를 제공한다. Native 접근성 초점 검증은 아직 남아 있다.
206
226
 
207
227
  ## 근거
228
+ - 구분선 시작은 위 행의 글자 열: 2026-10-17 사용자 보고(leading 없는 행에서 구분선이 중간에서 잘린 것처럼 보임). 고정 시작 52가 leading 없는 행에도 적용돼 Web 402 대시보드 기록 목록에서 행 글자 16 / 구분선 68~394(콘텐츠 끝 386보다 8 더 나감)였다. 위 행의 leading 여부로 시작을 정하고 끝을 콘텐츠 가장자리로 맞춘 뒤 글자 16 / 구분선 16~386, leading 있는 목록은 글자 85 / 구분선 85~369(콘텐츠 끝 369), RTL도 대칭으로 측정됐다.
208
229
  - 구분선·grouped 여백: 2026-10-17 사용자 결정(행 글자는 여백 선 16, 표면만 바깥으로 8). 행 콘텐츠 상자가 목록 콘텐츠 가장자리로 옮겨져 `indented` 시작이 60 → 52가 되어도 화면에서 보이는 위치(글자 시작)는 같다. 기본 화면에서 행 글자가 24, 섹션 제목이 16이던 실측은 [ListRow 근거](list-row.md#근거)에 있다. 이전 plain 목록의 `overflow: hidden`은 grouped 모서리만을 위한 것이라 grouped로 옮겼다.
@@ -95,4 +95,8 @@ import { NotificationItem } from "@hjmds/react-native/screens";
95
95
  | 상태·시각 줄 | 설명 아래 caption `Text` | `description`과 줄바꿈(`\n`)으로 합친 한 문자열 |
96
96
  | 이동 | `href`, `onClick` | `onPress` |
97
97
  | 추가 슬롯 | `className`, `layoutStyle` | `titleMetadata`, `trailingAction`, `trailingText`, `layoutStyle` |
98
- | 제목 굵기 조정 | `Text emphasis` | 렌더러 비공개 입력(`hjmTitleEmphasis`)으로 안 읽음 700(`fontWeight.bold`)·읽음 400(`fontWeight.regular`). 제거된 `ListRow.titleStyle`을 사용하지 않는다. `NotificationItemProps`에는 `titleStyle`이 없다(1.13.0 변경) |
98
+ | 제목 굵기 조정 | `Text emphasis` | 렌더러 비공개 입력(`hjmTitleEmphasis`)으로 안 읽음 `strong` 500(제목 굵기)·읽음 `regular` 400. 제거된 `ListRow.titleStyle`을 사용하지 않는다. `NotificationItemProps`에는 `titleStyle`이 없다(1.13.0 변경) |
99
+
100
+ ## 근거
101
+
102
+ 2026-10-17 사용자 결정("굵기 좀더 줄여"): 모든 글자 굵기를 한 단계 낮췄다. 큰 숫자·선택 상태 600(큰 제목 단계는 같은 날 폐지), 제목·강조 500, label 500, 본문 400이며 정본은 [타이포그래피](../tokens/typography.md)다.
@@ -87,6 +87,7 @@ import { OnboardingScreen } from "@hjmds/react-native/screen-flows";
87
87
  | `skip` | 선택. Web은 헤더 actions, Native는 본문 진행 문구 다음에 보조 버튼으로 놓인다 |
88
88
  | `progressLabel(current, total)` | 1부터 센 현재 단계와 전체 수로 진행 문구를 만든다 |
89
89
  | `layoutStyle` | 선택. ScreenLayout 루트의 공개 배치 슬롯 |
90
+ | `safeAreaBottom` (Native) | 선택, 기본 0. ScreenLayout에 그대로 넘겨 footer(다음·이전) 아래 여백이 `max(16, inset)`이 된다. 바깥 SafeAreaView bottom·`16 + inset` 패딩 래퍼 대신 쓴다(1.28.1, 2026-10-17 야잘알·다에리가 각자 감쌌다) |
90
91
 
91
92
  ## 배치
92
93
 
@@ -95,7 +95,7 @@ import { PhotoSourceSheet } from "@hjmds/react-native/screen-flows";
95
95
  | --- | --- | --- |
96
96
  | 크기 | Sheet 기본 `placement="bottom"`·`size="auto"`(내용 높이, 화면 높이 0.9 상한), Web 최대 폭 640; 선택 버튼은 `tone="secondary"` `Button` 두 개 | `sheetRecipe`, `screen-flows.tsx` |
97
97
  | 간격 | 시트 안 여백은 Sheet 소유(두 플랫폼 좌우 `spacing.lg` 20 · 위아래 `spacing.sm` 12 + 하단 safe area); 두 버튼 사이 `spacing.xs` 8(세로로 쌓은 버튼, `actionGroupRecipe.stackedGap`); 바깥에 padding을 더하지 않는다 | Web `.hjm-sheet__body`, Native `sheetRecipe.content`, `Stack gap="xs"` |
98
- | 순서·정렬 | Sheet 제목·닫기 → 앨범 → 촬영(`cameraAvailable`일 때만) | Web `screen-flows.tsx` 59–60행, Native 50–51행 |
98
+ | 순서·정렬 | Sheet 손잡이 → 앨범 → 촬영(`cameraAvailable`일 때만). `labels.title`은 보이지 않는 시트 이름(2026-10-17 사용자 결정: 시트는 보이는 제목 없이 `title`을 접근성 이름으로만 쓴다) | Web `screen-flows.tsx` 59–60행, Native 50–51행 |
99
99
  | 고정·스크롤 | 화면 하단에 뜨는 오버레이(배경 막) — 선택 뒤 picker 호출 시점은 플랫폼 차이 참고 | `Sheet` |
100
100
  | 좁은 폭·큰 글자 | 버튼 문구는 줄바꿈되고 Sheet 높이가 늘어난다(0.9 상한을 넘으면 본문 스크롤); 카메라가 없으면 버튼 하나만 남는다 | `sheetRecipe.content.maxHeightRatio` |
101
101
 
@@ -85,6 +85,8 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
85
85
  | `contentInset` | `"default"` · `"none"` · `"body-edge"` | `"default"` | `none`은 헤더·notice·본문·footer padding을 0으로(Native는 footer 위 테두리도 뺀다). host가 모든 여백을 이미 줄 때 쓴다. `body-edge`는 헤더·notice·footer 여백(16)과 footer 테두리를 유지하고 **본문 좌우 padding만 0**으로 한다. 자기 좌우 여백을 가진 전체 폭 목록 행(ConversationRow·NotificationRow 등 행 padding 16)을 본문에 바로 둘 때 쓴다(미게시). ListRow·NotificationItem은 `default`에 둔다. 값 목록은 계약 `ScreenContentInset`·`screenContentInsets` |
86
86
  | `presentation` | `"dashboard"` · `"editorial"` · `"landscape"` | 가장 가까운 profile.screens.overview / 없으면 기존 골격 | [디자인 프로필](../../design-profile.md)의 제공 범위를 따른다. 명시값이 프로필을 이긴다. 헤더 배치·읽기 폭만 바꾸고 상태·입력·행동 슬롯은 유지한다 |
87
87
  | `as` (Web) | `"main"` · `"section"` | `"main"` | 제품 셸에 이미 `<main>`이 있으면 `section` |
88
+ | `initialFocus` | `first-input` · `none` | `none` | 1.28.1. 진입 시 텍스트 입력 하나에 한 번 포커스한다. 아래 [진입 포커스](#진입-포커스) |
89
+ | `safeAreaBottom` (Native) | 0 이상의 유한수 | `0`(미게시) | 화면이 닿는 창 아래 가장자리의 safe area(`useSafeAreaInsets().bottom`, 탭바 위·이미 아래를 들인 host 안이면 0). 마지막 영역(footer, 없으면 본문)이 아래 여백을 `max(16, inset)`으로 한 번만 준다. 넘길 때는 host의 아래 SafeAreaView edge를 빼고, 둘을 같이 쓰지 않는다. 음수·무한대는 `RangeError` |
88
90
  | `layoutStyle` | `HjmCompositionStyleProp` | 없음 | 화면 루트 배치 전용(예: 분할 화면의 `flex`·`width`). ScreenLayout 위에 만든 화면은 모두 이 prop을 루트까지 넘긴다 |
89
91
  | `className` (Web) | `string` | 없음 | 식별·배치 보조. 색·여백을 덮지 않는다 |
90
92
  | `testID` · `scrollRef` · `scrollProps` (Native) | `string` · `Ref<ScrollView>` · `refreshControl`·`keyboardDismissMode`·스크롤 막대 표시·`onScroll`·`onScrollBeginDrag`·`onScrollEndDrag`·`scrollEventThrottle`·`alwaysBounceVertical`·`automaticallyAdjustKeyboardInsets` | 없음 | ScrollView가 있을 때(`scroll="screen"` 또는 상태 교체 중)만 `scrollRef`·`scrollProps`가 연결된다 |
@@ -97,8 +99,27 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
97
99
  | 간격 | 바깥 padding `screenPatternRecipe.padding`(`spacing.md` 16)을 헤더 사방·notice 좌우·본문 사방·footer 사방에 준다(`contentInset="none"`이면 0, `"body-edge"`면 본문 좌우만 0); 헤더 안 `exit`·제목·`actions` 간격 `itemGap`(`spacing.sm`) 12(두 플랫폼, `contentInset="none"`이어도 유지); 상태 안내는 위아래 `sectionGap`(`spacing.xl`) 24, 안내–`stateAction` `stateGap`(`spacing.md`) 16 | Web `src/screens.tsx`(`--hjm-screen-item-gap`·`--hjm-screen-state-gap`)·`.hjm-screen__*`, Native `ScreenLayout` |
98
100
  | 제목 | 기본 가운데 `Heading` `screenHeaderRecipe.centeredTitleLevel`(level5), `headerAlignment="start"`면 `titleLevel`(level3). 추가 기능 버튼(`actions`)은 끝(오른쪽) 위, 나가기(`exit`)는 시작(왼쪽) 위. 헤더 높이는 두 플랫폼 모두 `screenHeaderRecipe`의 고정 띠 | `screenHeaderRecipe` |
99
101
  | 순서·정렬 | 헤더(`exit` → 제목·설명 → `actions`) → notice → 본문 → footer; 상태 안내는 본문 세로 가운데 `exit`는 HJM이 `edge="start"`로 그려 아이콘 칸이 헤더 여백 선 16에 온다(Web·Native 31 → 16, 가운데 헤더의 같은 폭 양쪽 열·제목 중앙은 그대로). 끝 `actions`의 마지막 ghost IconButton은 제품이 `edge="end"`를 준다(2026-10-17 실측 감사, [IconButton `edge`](icon-button.md#배치)) | 렌더 순서 |
100
- | 고정·스크롤 | 헤더·notice·footer 고정, `scroll="screen"`은 본문이 스크롤, `"content"`는 자식이 스크롤; footer 위 테두리 1, Web은 하단 safe area만큼 padding을 늘린다 | Web `data-scroll`·`.hjm-screen__footer`, Native `ScrollView` |
101
- | 좁은 폭·큰 글자 | 제목 열 최소 폭 `headerMinWidth` 120 × 글자 배율, 모자라면 `actions`가 다음 줄로 내려간다; 제목은 줄바꿈(`overflow-wrap: anywhere`)되고 자르지 않는다; Native safe area·탭바 inset은 host | `screenPatternRecipe.headerMinWidth`, Web `.hjm-screen__heading` |
102
+ | 고정·스크롤 | 헤더·notice·footer 고정, `scroll="screen"`은 본문이 스크롤, `"content"`는 자식이 스크롤; footer 위 테두리 1. 마지막 영역(footer, 없으면 본문)의 아래 여백은 `max(16, 하단 inset)`이다 — Web은 `env(safe-area-inset-bottom)`, Native는 `safeAreaBottom`. 16 + inset으로 더하지 않으며, 키보드가 열려 있으면 키보드가 홈 인디케이터를 덮으므로 16만 둔다 | Web `data-scroll`·`.hjm-screen__footer`, Native `ScrollView` |
103
+ | 좁은 폭·큰 글자 | 제목 열 최소 폭 `headerMinWidth` 120 × 글자 배율, 모자라면 `actions`가 다음 줄로 내려간다; 제목은 줄바꿈(`overflow-wrap: anywhere`)되고 자르지 않는다; Native 위·옆 safe area·탭바 inset은 host, 아래 inset은 `safeAreaBottom` | `screenPatternRecipe.headerMinWidth`, Web `.hjm-screen__heading` |
104
+
105
+ ## 진입 포커스
106
+
107
+ 2026-10-18 사용자 요청("input이 있는 화면에 진입하면 포커스되도록, 웹앱 둘 다"). 앱마다 `autoFocus`·지연 `ref.focus()`를 붙이던 것을 화면 골격이 맡는다. 입력 칸(TextField·TextArea·SearchField·PasswordField, 작성창의 TextArea)은 가장 가까운 화면·시트 범위에 자신을 등록하고, 범위가 진입 뒤 한 칸을 고른다. 규칙의 정본은 contracts `resolveInitialFocusTarget`·`initialFocusDefaults`(`screen-patterns`)다.
108
+
109
+ | 구성 | 기본값 | 이유 |
110
+ | --- | --- | --- |
111
+ | `ScreenLayout`(일반 화면) | `none` | 입력이 화면의 주 행동인지 화면이 알 수 없다. 주 입력 화면은 `initialFocus="first-input"` |
112
+ | `SearchScreen` | `first-input`(검색어) | 검색이 화면의 목적 |
113
+ | `EditorScreen` | `first-input` | 작성·편집이 화면의 목적 |
114
+ | `ChatScreen`·`CommentThreadScreen` | `none` | 입력은 읽기의 보조. 키보드가 기록을 가린다. 같은 prop으로 켤 수 있다 |
115
+ | `Sheet`·`Dialog`·`AlertDialog` | `single-input` | 텍스트 입력이 하나뿐일 때만(이름 바꾸기 등). 여럿이면 기존 포커스 규칙 |
116
+
117
+ - 고르는 순서: `initialFocusTarget` 칸 → 등록(트리) 순서 첫 칸. 비활성·읽기 전용 칸, `initialFocusTarget={false}` 칸은 고르지 않는다.
118
+ - 시점: Native는 화면·시트 진입 전환이 끝난 뒤(`InteractionManager`, 시트는 표시 뒤) — 키보드 애니메이션이 전환과 겹치지 않는다. Web은 mount 직후, `preventScroll`.
119
+ - 빼앗지 않는다: 사용자가 이미 입력 칸에 있으면 그대로 둔다(Native 포커스된 입력, Web 편집 가능한 요소). Web은 열린 모달(`aria-modal`) 밑의 화면 칸을 고르지 않는다.
120
+ - 다시 진입: 화면은 mount당 한 번이다. 스택에서 돌아온 화면(이미 mount)은 다시 포커스하지 않는다 — 돌아온 맥락을 키보드가 가리고, HJM은 내비게이션 포커스 신호를 모른다. 시트·대화상자는 열 때마다 다시 고른다.
121
+ - 모션 감소는 애니메이션에만 적용하고 포커스는 그대로 한다.
122
+ - 제품은 `autoFocus`·`setTimeout(() => ref.focus())`를 쓰지 않고 `initialFocus`·`initialFocusTarget`으로 표현한다.
102
123
 
103
124
  ## 꼭 지킬 것
104
125
 
@@ -109,11 +130,13 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
109
130
 
110
131
  - Web의 화면 스크롤 본문은 Tab으로 진입할 수 있다. `scroll="content"`의 정상 본문은 자식이 키보드 스크롤을 소유하지만, 오류·빈 상태 등 대체 안내는 화면 스크롤로 바뀌며 Tab 진입도 함께 복원된다.
111
132
  - Web에서 본문에 초점을 가진 입력·재시도 버튼이 상태 전환으로 제거되면 같은 본문으로 초점을 복구한다. `scroll="content"`의 본문은 이 복구만을 위한 `tabIndex=-1`을 가지며 일반 Tab 순서에는 추가되지 않는다. 사용자가 다른 컨트롤이나 바깥 영역으로 초점을 옮긴 경우에는 이동시키지 않는다. 2026-10-13 재시도·권한 제한 전환에서 초점이 body로 유실된 경로를 수정했다.
133
+ - **화면 여백은 ScreenLayout이 소유한다(기본 `contentInset`).** 화면을 바깥 `Container gutter`로 감싸고 `contentInset="none"`을 주는 구성은 지원하지 않는다. 행의 표면 bleed, footer·BottomCTA 바, 끝 컨트롤의 `edge`는 모두 ScreenLayout이 여백을 가진다는 전제로 맞춰져 있어, 그 구성에서는 행 표면이 본문 끝에서 잘리고 footer가 여백 안에 떠 있는 상자가 된다. `none`은 host가 모든 inset을 소유하는 기존 의미 그대로다(새 동작 없음).
112
134
  - 가상화 목록(FlatList, VirtualList)을 넣으면 `scroll="content"`로 둔다. 스크롤을 이중으로 중첩하지 않는다.
113
135
  - 새로고침 실패·저장 오류는 `state`를 바꾸지 말고 `ready` + `notice`로 알린다. `loading`으로 바꾸면 본문이
114
136
  unmount되어 입력 초안이 사라진다.
115
137
  - `state.title`·문구는 i18n 키로 넣는다. ready 외 상태의 빈 `title`은 `TypeError`다.
116
- - Web host는 실제 남은 높이(`height:100%` 등)를, Native host는 safe area·탭바 inset을 먼저 처리한다.
138
+ - **`safeAreaBottom`을 받는 화면 구성(Native, 1.28.1 감사):** ScreenLayout을 그리는 공개 구성은 모두 같은 prop을 받아 자기 ScreenLayout에 넘긴다 — `SettingsScreen`·`NotificationInboxScreen`·`ChatScreen`·`OverviewScreen`·`EditorScreen`·`ProfileScreen`·`ModerationScreen`·`MediaSelectionScreen`·`PermissionScreen`·`CommentThreadScreen`·`SearchScreen`(+`footer`)·`SavedItemsScreen`(ListDetailScreen 경유)·`ListDetailScreen`(목록·상세 pane 둘 다)·`OnboardingScreen`. 로그인은 `AuthScreenLayout` `safeAreaBottom`(같은 max 규칙, 기준은 paddingBlock). 하단 막대를 직접 그리는 `BottomCTA`·`BottomNavigation`은 각자 `safeAreaBottom`, FAB는 `safeAreaBottomInset`을 받는다. `Layout`(primitives)의 `footer`는 배치 슬롯일 뿐 inset을 정하지 않는다.
139
+ - Web host는 실제 남은 높이(`height:100%` 등)를, Native host는 위·옆 safe area와 탭바 inset을 먼저 처리한다. 아래 홈 인디케이터는 host가 SafeAreaView로 들이지 말고 `safeAreaBottom`으로 넘긴다(들인 뒤 footer 16을 더하면 버튼 아래가 50이 된다).
117
140
  HJM은 기종별 높이를 추측하지 않는다.
118
141
  - 배치는 Web·Native 모두 `layoutStyle`로 한다(Web은 `className`도 있다). 색·여백을 덮지 않는다.
119
142
 
@@ -141,4 +164,6 @@ dashboard는 가로 헤더/1200 목록 폭, editorial은 세로 헤더/720 읽
141
164
 
142
165
  ## 근거
143
166
 
167
+ - 하단 여백 `max(16, inset)`(Native `safeAreaBottom`): 2026-10-17 사용자 보고 "전체적으로 버튼 아래 여백이 많아". Native Storybook의 사진 선택과 업로드 화면에서 버튼 아래가 약 33pt였다. 원인은 둘이었다. 화면 스토리의 route host가 고정 높이 720이라 iPhone 17 Pro 스토리 영역 737보다 17 짧았고(footer 16 + 빈 칸 17), 컴포넌트는 Native footer가 safe area를 다루지 않아 host의 아래 inset 위에 16을, footer 안 BottomCTA의 `safeAreaBottom`도 16 위에 더했다(16 + 34 = 50). Web footer는 이미 `max(16, env())`였지만 footer 안 BottomCTA의 `safeAreaBottom`은 그 위에 더했다. 표준을 "마지막 컨트롤 아래 = footer 위 여백 16, 홈 인디케이터 기기에서는 max(16, inset)"으로 정하고 footer가 한 번만 소유하게 했다.
168
+ - 화면 여백은 ScreenLayout 소유, 바깥 여백 + `none` 미지원: 2026-10-17 바깥 여백 호스트 예제 사용자 검토. utilverse식(`Container gutter="regular"` 20 + `contentInset="none"`)을 HJM에서 지원하려고 본문 확장·footer 바 소유를 붙인 예제가 어색하다는 판단으로 지원을 접었다(2026-10-15 공통 구조는 HJM 소유 원칙). 측정상 그 구성에서는 Web 402·1280에서 행 표면이 양쪽 8 잘리고 본문이 옆으로 8 스크롤되며, Native 3x에서 선택 테두리 옆 선이 그려지지 않았다. 제품은 바깥 여백을 걷고 기본 inset을 쓴다.
144
169
  - `contentInset="body-edge"`: 2026-10-17 사용자 결정. 화면 검토 S5#9에서 대화 목록 아바타가 화면 끝에서 약 32pt 안쪽에서 시작했다. 본문 padding 16과 ConversationRow 자체 padding 16이 겹쳤다(인스타그램 DM은 약 16). 앱 약 45곳이 "host가 모든 여백을 소유한다"는 뜻으로 `none`을 쓰고 있어 `none`의 의미를 바꿀 수 없었으므로 값을 새로 추가했다. ChatScreen의 10 타임라인 여백은 `default`일 때만 공통 여백을 대신한다.
@@ -62,6 +62,7 @@ import { SearchField } from "@hjmds/react-native/inputs";
62
62
 
63
63
  | prop | 값 | 기본값 | 설명 |
64
64
  | --- | --- | --- | --- |
65
+ | `initialFocusTarget` | `boolean` | — | 1.28.1. 둘러싼 화면·시트가 진입 포커스를 켰을 때 `true`면 이 칸을 고르고, `false`면 절대 고르지 않는다. 생략하면 트리 순서대로 후보다. 비활성·읽기 전용 칸은 고르지 않는다. 앱별 `autoFocus`·지연 `ref.focus()` 대신 쓴다([진입 포커스](screen-layout.md#진입-포커스)). TextField·TextArea·SearchField·PasswordField 공통 |
65
66
  | `size` | `medium` · `large` | `medium` | 최소 높이 44 · 52 |
66
67
  | `variant` | `surface` · `inset` | — | — |
67
68
  | `shape` | `medium` · `large` · `full` | `medium` | — |
@@ -157,12 +157,14 @@ import { SearchScreen } from "@hjmds/react-native/screen-flows";
157
157
  | `resultSummary.countVisibility` | `visible` · `hidden` | `visible` | `hidden`은 시각적 개수·개수 Skeleton만 숨기며 정렬·로딩 행·빈 상태·접근성 개수 알림은 유지한다. 2026-10-14 야잘알 검색 결과에서 개수 없는 목록을 요청해 추가한 선택형 축(미게시)이다 |
158
158
  | `resultSummary.sort` | `{ label, triggerLabel, value, options: { id, label }[], onChange(id), icon? }` (Native는 `dismissLabel` 추가) | 없음 | 결과 머리 끝 Menu(single). 바꾸면 본문을 맨 위로 |
159
159
  | `appliedFilters` | `{ items: { key, label }[], removeLabel(label), onRemove(key), clearAllLabel, onClearAll(), removeIcon? }` | 없음 | 결과 머리 아래 × 칩 + `모두 해제`. 개수는 trigger 이름과 0건 원인 판정에도 쓴다 |
160
- | `filterSheet` | `{ open, onOpenChange(open), title, value, onApply(next), count(draft): number \| null, reset(draft), isDefault(draft), renderContent(draft, setDraft), labels: { close, reset, apply(count) }, size?, trigger? }` | 없음 | 열 때 `value`를 초안으로 복사, 닫히면 초안을 버림, 주 행동에서만 `onApply`. `count = 0`이면 주 행동 비활성, `isDefault`면 초기화 비활성. `trigger`를 주면 칩 줄 맨 앞에 `label(적용 개수)` Chip |
160
+ | `filterSheet` | `{ open, onOpenChange(open), title, value, onApply(next), count(draft): number \| null, reset(draft), isDefault(draft), renderContent(draft, setDraft), labels: { close, reset, apply(count) }, size?, trigger? }` | 없음 | 열 때 `value`를 초안으로 복사, 닫히면 초안을 버림, 주 행동에서만 `onApply`. `count = 0`이면 주 행동 비활성, `isDefault`면 초기화 비활성. `trigger`를 주면 칩 줄 맨 앞에 `label(적용 개수)` Chip. `title`은 필터 시트의 보이지 않는 접근성 이름이다(2026-10-17 사용자 결정: 시트 제목 줄 없음). 가림막을 누르면 닫히고 초안을 버린다 |
161
161
  | `filtersOverflow` | `wrap` · `scroll` | `wrap` | `scroll`이면 `filters`를 한 줄 가로 스크롤 영역에 넣고 화면 좌우 여백(`spacing.md` 16, `contentInset="none"`이면 0)에 `hostGutter`를 더한 만큼 가장자리까지 넓힌다. 첫 칩은 검색 입력과 같은 시작선에 놓인다 |
162
162
  | `hostGutter` | `none` · `compact` · `regular` · `spacious` | `none` | 1.13.1 변경. 이 화면을 감싼 host가 이미 준 좌우 여백 이름(`containerRecipe.gutters`: 0 · `spacing.md` 16 · `spacing.lg` 20 · `spacing.xl` 24). Container 안이면 그 `gutter`, Sheet 본문 안이면 `regular`(`sheetRecipe.content.paddingHorizontal`과 같은 `spacing.lg`). `scroll` 줄만 이 값을 쓴다 |
163
163
  | `recentSearches` | `ReactNode` | 없음 | `query`가 공백일 때만 본문 맨 위에 보인다 |
164
164
  | `children` | `ReactNode` | 필수 | 결과 |
165
- | 나머지 | `ScreenLayout`과 같음(`children`·`footer` 제외) | — | `notice`는 필터 아래에 붙는다 |
165
+ | `initialFocus` | `first-input` · `none` | `first-input` | 1.28.1. 진입 후 검색어 입력에 한 번 포커스한다(검색 화면의 주 입력). 결과로 돌아온 화면(이미 mount)은 다시 포커스하지 않는다 |
166
+ | `footer` | `ReactNode` | 없음 | ScreenLayout footer 그대로(1.28.1): 결과 아래 고정 영역(주 행동·광고 막대). 위 경계·여백·아래 inset을 footer가 맡으므로 화면 밖에 막대를 따로 두고 inset을 직접 주지 않는다(unairplane 항공편 조회, 2026-10-17) |
167
+ | 나머지 | `ScreenLayout`과 같음(`children` 제외, Native `safeAreaBottom` 포함) | — | `notice`는 필터 아래에 붙는다 |
166
168
 
167
169
  ## 배치
168
170
 
@@ -78,7 +78,7 @@ import { Section } from "@hjmds/react-native/primitives";
78
78
  | 항목 | 값 | 근거 |
79
79
  | --- | --- | --- |
80
80
  | 크기 | 자체 배경·테두리·여백이 없다. 폭은 부모를 따른다 | `.hjm-section` |
81
- | 글자 | 제목 `title` 18/26 bold, 설명 `caption` 11/16(두 플랫폼). 2026-10-06까지 Web은 둘 다 본문 14를 물려받았다(1.13.0 변경) | `sectionRecipe.title`·`description`, `.hjm-section__title`·`__description` |
81
+ | 글자 | 제목 `title` 18/26 500(제목 굵기), 설명 `caption` 11/16(두 플랫폼). 2026-10-06까지 Web은 둘 다 본문 14를 물려받았다(1.13.0 변경) | `sectionRecipe.title`·`description`, `.hjm-section__title`·`__description` |
82
82
  | 간격 | 머리와 본문 사이 `spacing.xs` 8, 글자 묶음과 행동 사이 `spacing.sm` 12, 제목과 설명 사이 `spacing.xxs` 4. Section끼리의 간격은 Section이 주지 않는다. 감싸는 [Stack](stack.md)이나 `layoutStyle`로 `layout.sectionGap`(`spacing.xl` 24)을 둔다 | `sectionRecipe.gap`·`headerGap`·`copyGap`, `layout.sectionGap` |
83
83
  | 순서·정렬 | 머리 행동은 끝(LTR 오른쪽)에 하나, 글자 묶음과 세로 가운데 정렬. 보통 [Link](link.md) 또는 `tone="link"` [Button](button.md) | `.hjm-section__header`, Native `Section` |
84
84
  | 고정·스크롤 | 고정되지 않는다. 화면 본문 스크롤 안에 둔다 | — |
@@ -109,3 +109,7 @@ import { Section } from "@hjmds/react-native/primitives";
109
109
  | heading 수준 | `headingLevel` | 없음(header role 고정) |
110
110
  | 루트 | `<section>` | `View` |
111
111
  | 배치 | `className`, `layoutStyle`(HTML `style`도 받음) | `layoutStyle`, slot `*Style`(`style`은 제거된 속성) |
112
+
113
+ ## 근거
114
+
115
+ 2026-10-17 사용자 결정("굵기 좀더 줄여"): 모든 글자 굵기를 한 단계 낮췄다. 큰 숫자·선택 상태 600(큰 제목 단계는 같은 날 폐지), 제목·강조 500, label 500, 본문 400이며 정본은 [타이포그래피](../tokens/typography.md)다.
@@ -80,7 +80,7 @@ import { SegmentedControl } from "@hjmds/react-native/inputs";
80
80
 
81
81
  | 항목 | 값 | 근거 |
82
82
  | --- | --- | --- |
83
- | 크기 | 항목 최소 높이 `medium` 44 · `small` 36 + hitSlop 4. 항목 좌우 여백 `medium` `spacing.md` 16 · `small` `spacing.sm` 12(두 플랫폼. Web `small`은 `::after`로 위아래 4를 넓힌다) | `segmentedControlRecipe.sizes`, `.hjm-segmented__item` |
83
+ | 크기 | 바깥 높이(트랙 테두리·안쪽 여백 포함) `height`는 같은 크기의 Button·IconButton·Chip과 같다: `medium` 44 · `small` 36. 항목 최소 높이는 그보다 트랙 안쪽 여백 4와 테두리 1을 위아래로 뺀 `medium` 34 · `small` 26이고, 위아래로만 넓힌 터치 영역(`hitSlop` 5 · 9, Web `::after`)으로 44를 채운다. 작은 트랙은 바깥으로 4 더 넓어지되 레이아웃 높이는 늘지 않는다. 큰 글자에서 세로로 쌓이면 항목마다 44. 항목 좌우 여백 `medium` `spacing.md` 16 · `small` `spacing.sm` 12. 2026-10-17 사용자 요청("높이 맞춰")으로 같은 줄의 칩과 높이를 맞췄다(이전에는 항목 높이 기준이라 트랙이 46·54였다) | `segmentedControlRecipe.sizes`, `.hjm-segmented__scroller`, `.hjm-segmented__item` |
84
84
  | 간격 | 트랙 안쪽 여백·항목 간격 `container.padding`·`gap` `spacing.xxs` 4. 트랙 모서리 `radius.lg` 16(테두리 1). 선택 항목 모서리 `radius.md` 12, 선택 테두리 2(`stroke.strong`). 아래 내용과 `layout.contentGap` 16 | `segmentedControlRecipe.container`·`item`, `.hjm-segmented__items` |
85
85
  | 순서·정렬 | 항목은 같은 폭으로 나뉘고(Native `flex: 1`, Web `flex: 1 1 0`) 트랙은 부모 폭을 채운다. 전환할 내용 바로 위, 화면 좌우 여백(`layout.pagePadding`) 안 | Native `SegmentedControl`, `.hjm-segmented__item` |
86
86
  | 고정·스크롤 | 고정되지 않는다. Web 항목은 글자보다 좁아지지 않아(`min-inline-size: max-content`) 넘치면 트랙이 가로 스크롤된다. Native는 스크롤하지 않는다 | `.hjm-segmented__items`(overflow-x) |
@@ -13,7 +13,7 @@
13
13
  현재 화면 위에 모달로 띄우는 보조 작업 패널에 쓴다. 설정 한 항목 편집, 선택 목록(테마·언어),
14
14
  짧은 입력 폼, 공유·사진 출처 선택처럼 끝나면 원래 화면으로 돌아오는 작업이다. 기본은 하단에서 올라온다.
15
15
 
16
- 제목 정렬, 키보드 높이, top inset, 본문 maxHeight 보정은 Sheet가 소유한다. 제품 wrapper에서 Modal·키보드 listener·강제 maxHeight를
16
+ 제목은 화면에 그리지 않고 대화상자의 접근성 이름으로만 쓴다(2026-10-17). 키보드 높이, top inset, 본문 maxHeight 보정은 Sheet가 소유한다. 제품 wrapper에서 Modal·키보드 listener·강제 maxHeight를
17
17
  다시 만들지 않는다.
18
18
 
19
19
  ## 쓰지 않을 때
@@ -78,16 +78,17 @@ import { Sheet } from "@hjmds/react-native/overlays";
78
78
 
79
79
  | prop | 값 | 기본값 | 설명 |
80
80
  | --- | --- | --- | --- |
81
+ | `initialFocus` | `single-input` · `first-input` · `none` | `single-input` | 1.28.1. 열린 뒤(Native: 표시·진입 애니메이션 뒤, Web: 포커스 이동 시점) 포커스할 텍스트 입력. 기본은 텍스트 입력이 **하나뿐일 때만** 그 칸(이름 바꾸기·메모 한 줄), 여럿이면 기존 규칙(Web 첫 포커스 가능 요소, Native 접근성 포커스) 그대로. `first-input`은 `initialFocusTarget` 칸, 없으면 첫 칸. `initialFocusRef`(Web)가 있으면 그쪽이 이긴다. 열 때마다 다시 고른다 |
81
82
  | `placement` | `bottom` · `start` · `end` | `bottom` | 나오는 가장자리 |
82
83
  | `size` | `auto` · `medium`(60%) · `large`(85%) · `full`(100%) | `auto` | `auto`는 내용 높이. 비율은 화면 높이 기준(Web `dvh`), 두 플랫폼 모두 위 safe area(Native는 키보드도)를 뺀 남은 높이를 넘지 않는다. `maxHeightRatio` 0.9 상한은 `auto`에만 걸린다 |
83
84
  | `open` · `defaultOpen` | `boolean` | 비제어 `false` | Web은 제어하면 `onOpenChange` 필수, 비제어면 `trigger` 필수 |
84
85
  | `onOpenChange` | `(open: boolean, detail: { reason }) => void` | — | reason: `trigger` · `close-action` · `escape` · `back` · `outside` · `swipe` · `programmatic`. Android 하드웨어 back은 Native에서만 `back` |
85
86
  | `onDismissComplete` | `(detail: { reason }) => void` | — | 표면이 실제로 사라진 뒤 한 번. 다음 modal·화면 이동은 여기서 |
86
- | `dismissPolicy` | `{ dismissible?, dismissWhileBusy?, outsideDismiss?, escapeOrBackDismiss?, swipeDismiss? }` | `dismissible` true · `dismissWhileBusy` false · `outsideDismiss` 하단 false / 옆 true · `escapeOrBackDismiss` true · `swipeDismiss` true(미게시 변경, 이전 false) | 닫기 허용 범위. `resolveSheetDismissPolicy(placement, override)` |
87
+ | `dismissPolicy` | `{ dismissible?, dismissWhileBusy?, outsideDismiss?, escapeOrBackDismiss?, swipeDismiss? }` | `dismissible` true · `dismissWhileBusy` false · `outsideDismiss` true(모든 위치, 미게시 변경, 이전 하단 false) · `escapeOrBackDismiss` true · `swipeDismiss` true(미게시 변경, 이전 false) | 닫기 허용 범위. `resolveSheetDismissPolicy(placement, override)` |
87
88
  | `keyboardAvoidance` (Native) | `boolean` | `true`(미게시 변경, 이전 `false`) | 모달이 키보드 높이만큼 비킨다. 안쪽 `ScreenLayout`은 중복 처리하지 않는다 |
88
89
  | `showClose` | `boolean` | 하단 `false` · 옆 `true`(미게시) | 머리의 × 표시. 하단 시트는 드래그로 닫는 것이 기본이라 끈다 |
89
90
  | `busy` | `boolean` | `false` | 사용자 닫기를 막는다 |
90
- | `title` · `closeLabel` | 문구 | 필수 | Native `title`이 element면 `accessibilityTitle`(string) 필수 |
91
+ | `title` · `closeLabel` | 문구 | 필수 | `title`은 화면에 보이지 않는다. Web은 `aria-labelledby`가 가리키는 시각 숨김 `h2`, Native는 대화상자 `accessibilityLabel`과 배치 밖 1pt 머리글이다(미게시). Native `title`이 element면 `accessibilityTitle`(string) 필수이며 element 자체는 그려지지 않는다. `closeLabel`은 손잡이·×·가림막 닫기의 이름 |
91
92
  | `description` · `footer` | 노드(Native `description`은 `string`) | — | `footer`는 스크롤 밖 고정. 저장 행동은 여기 |
92
93
  | `detents`(Web) · `activeDetent` · `onDetentChange` | `readonly ("medium" \| "large" \| "full")[]` · `(detent) => void` | — | 사용자가 높이를 바꿀 단계. `detentLabels: { expand, collapse }` 필수 |
93
94
  | `keyboardAvoidance` · `scrollable`(Native) | `boolean` | `false` | 입력 폼은 둘 다 켠다 |
@@ -112,17 +113,17 @@ import { Sheet } from "@hjmds/react-native/overlays";
112
113
  | --- | --- | --- |
113
114
  | 크기 | 하단 시트 높이: `auto` 내용 높이(최대 화면 높이 × `maxHeightRatio` 0.9), `medium` 60%, `large` 85%, `full` 위 safe area 안 전체 높이(0.9 상한 없음). Web 하단 시트 최대 폭 640. 위 두 모서리만 `radius.xl` 24. 옆 시트(`start`·`end`): Web 폭 `min(28rem, 100%)`·높이 화면 − 32, Native 폭 88%(최대 420)·화면 높이 | `sheetRecipe.sizes`·`content`·`web`, `.hjm-sheet`, Native `Sheet` |
114
115
  | 간격 | 두 플랫폼 같은 값: 좌우 `content.paddingHorizontal`(`spacing.lg` 20), 위·아래 `content.paddingTop/Bottom`(`spacing.sm` 12, 하단 시트는 아래 safe area를 더함), 머리·본문·footer 사이 `body.gap`(`spacing.md` 16), footer 위 `footer.paddingTop`(`spacing.sm` 12), 제목–닫기 `header.gap`(`spacing.sm` 12), footer 버튼 사이 `footer.gap`(`spacing.sm` 12) | `.hjm-sheet__*`, `sheetRecipe.content`·`body`·`footer` |
115
- | 순서·정렬 | 머리(제목·설명 + 닫기) → 본문 → `footer`. 머리 최소 높이 44(`control.minTouchTarget`), 닫기는 끝 쪽에서 제목 세로 중앙. Web footer는 오른쪽 정렬 가로 줄 [보조][주]. Native footer는 세로 열이라 꽉 찬 폭 버튼을 주 행동 먼저 둔다. 하단 시트는 항상 위 가운데 36×4 손잡이(터치 최소 폭 44)를 둔다. `detents`가 있으면 높이 전환 버튼, 없으면 `closeLabel` 이름의 닫기 버튼이다. 손잡이·머리 영역을 아래로 끌어 `sheetDragRecipe.dismissDistanceRatio`(시트 높이의 25%) 또는 `dismissVelocity` 0.5px/ms를 넘기면 `swipe`로 닫고, 아니면 제자리로 돌아간다(미게시, Web·Native 동일) | `sheetRecipe.header`·`handle`, `.hjm-sheet__footer`, `.hjm-sheet__handle` |
116
- | 고정·스크롤 | 본문만 스크롤, 머리·`footer` 고정(Native는 `scrollable`일 때 ScrollView). 고정 높이(`medium`·`large`·`full`, 옆 시트)에서는 본문이 머리·footer를 뺀 남은 높이를 차지하고 `footer`는 시트 아래에 붙는다. 그래서 본문의 `flex: 1` 자식(SearchScreen, 목록 화면)이 그 높이를 채운다(Web `.hjm-sheet__body` `flex: 1 1 auto`, Native 본문 `flexGrow: 1`·`minHeight: 0`, 1.13.1 변경). `auto`는 내용 높이 그대로다. 저장·확인은 `footer`에. 아래 안전 영역은 시트가 자기 여백에 더한다(`spacing.sm` 12 + 아래 inset, Web은 `env(safe-area-inset-bottom)`). 위 inset은 시트가 올라갈 높이를 줄인다. 제품이 inset을 다시 더하지 않는다 | `.hjm-sheet__body`, `sheetRecipe.safeArea`, Native `Sheet` |
116
+ | 순서·정렬 | 손잡이(하단) → 머리(설명 + 닫기, 둘 다 없으면 그리지 않음) → 본문 → `footer`. 보이는 제목 줄은 없다(미게시). 머리 최소 높이 44(`control.minTouchTarget`), ×는 설명이 없어도 끝 쪽 같은 자리다. 기본 하단 시트는 머리가 없어 본문이 손잡이 영역 바로 아래 `body.gap` 16에서 시작한다. Web footer는 오른쪽 정렬 가로 줄 [보조][주]. Native footer는 세로 열이라 꽉 찬 폭 버튼을 주 행동 먼저 둔다. 하단 시트는 항상 위 가운데 36×4 손잡이(터치 최소 폭 44)를 둔다. `detents`가 있으면 높이 전환 버튼, 없으면 `closeLabel` 이름의 닫기 버튼이다. 손잡이·머리 영역을 아래로 끌어 `sheetDragRecipe.dismissDistanceRatio`(시트 높이의 25%) 또는 `dismissVelocity` 0.5px/ms를 넘기면 `swipe`로 닫고, 아니면 제자리로 돌아간다(미게시, Web·Native 동일) | `sheetRecipe.header`·`handle`, `.hjm-sheet__footer`, `.hjm-sheet__handle` |
117
+ | 고정·스크롤 | 본문만 스크롤, 머리·`footer` 고정(Native는 `scrollable`일 때 ScrollView). 스크롤 영역은 시트 좌우 여백(`sheetRecipe.content.paddingHorizontal` 20) 끝까지 넓고 여백은 스크롤 내용 안쪽에 둔다(Web `.hjm-sheet__body` padding, Native ScrollView `marginHorizontal: -20` + 내용 `paddingHorizontal: 20`). 그래서 본문 ListRow의 8 표면이 잘리지 않고 내용 위치는 그대로다. 고정 높이(`medium`·`large`·`full`, 옆 시트)에서는 본문이 머리·footer를 뺀 남은 높이를 차지하고 `footer`는 시트 아래에 붙는다. 그래서 본문의 `flex: 1` 자식(SearchScreen, 목록 화면)이 그 높이를 채운다(Web `.hjm-sheet__body` `flex: 1 1 auto`, Native 본문 `flexGrow: 1`·`minHeight: 0`, 1.13.1 변경). `auto`는 내용 높이 그대로다. 저장·확인은 `footer`에. 아래 안전 영역은 시트가 자기 여백에 더한다(`spacing.sm` 12 + 아래 inset, Web은 `env(safe-area-inset-bottom)`). 위 inset은 시트가 올라갈 높이를 줄인다. 제품이 inset을 다시 더하지 않는다 | `.hjm-sheet__body`, `sheetRecipe.safeArea`, Native `Sheet` |
117
118
  | 좁은 폭·큰 글자 | 폭 < 640이면 하단 시트가 화면 폭을 채운다. Web footer는 줄을 바꾼다. 큰 글자로 내용이 길어지면 `auto`는 최대 90%에서 멈추고 본문이 스크롤된다(`full`은 위 safe area까지) | `.hjm-sheet[data-placement="bottom"]`, `maxHeightRatio` |
118
119
 
119
120
  ```text
120
121
  폭 < 640(모바일) Web 넓은 폭: 가운데, 최대 640
121
122
  ┌──────────────────────────────┐ ┌────────────────────────────────────┐
122
- │ (뒤 화면 + scrim, 누르면 닫힘)│ │ (scrim) │
123
+ │ (뒤 화면 + scrim, 누르면 닫힘) │ │ (scrim, 누르면 닫힘) │
123
124
  │ ╭──────────────────────────╮ │ │ ╭────────────────────╮ │
124
- │ │ ▬▬ (Web detents일 때) │ │ │ │ 제목 [×] │ │
125
- │ │ 제목·설명 [×] │ │ ← 고정 │ │ 본문(스크롤) │ │
125
+ │ │ ▬▬ 손잡이 │ │ │ │ ▬▬ 손잡이 │ │
126
+ │ │ (설명) (showClose일 때 ×)│ │ ← 고정 │ │ 본문(스크롤) │ │
126
127
  │ │──────────────────────────│ │ │ │ [취소] [저장] │ │
127
128
  │ │ 본문 ↕ 스크롤 │ │ └──────┴────────────────────┴────────┘
128
129
  │ │──────────────────────────│ │
@@ -135,20 +136,21 @@ import { Sheet } from "@hjmds/react-native/overlays";
135
136
 
136
137
  ## 꼭 지킬 것
137
138
 
138
- - 하단 시트는 드래그로 내려 닫을 수 있어야 한다(필수). 배경 탭 닫기와 ×는 기본으로 끈다. 드래그를 쓸 수 없는 사용자를 위해 손잡이 닫기 버튼, Escape·Android back, VoiceOver 닫기 제스처는 유지한다. `swipeDismiss: false`는 결제 확인처럼 실수로 닫히면 안 되는 흐름에만 쓰고 이유를 제품 코드에 남긴다.
139
+ - 하단 시트는 드래그로 내려 닫을 수 있어야 한다(필수). 시트 밖(가림막)을 누르거나 클릭해도 닫힌다(모든 위치, 미게시). ×는 하단 시트에서 기본으로 끈다. 드래그를 쓸 수 없는 사용자를 위해 손잡이 닫기 버튼, 가림막 닫기(Native는 `closeLabel` 이름의 버튼), Escape·Android back, VoiceOver 닫기 제스처는 유지한다. Web은 누름이 가림막에서 시작할 때만 닫아 시트 안에서 시작한 드래그 선택은 닫지 않는다. `outsideDismiss: false`·`swipeDismiss: false`는 결제 확인처럼 실수로 닫히면 안 되는 흐름에만 쓰고 이유를 제품 코드에 남긴다.
140
+ - 저장하지 않은 입력을 버릴지 묻는 흐름은 제어형 `onOpenChange`에서 처리한다. 가림막(`outside`)·드래그(`swipe`)·Escape·back·손잡이 모두 같은 경로로 들어오므로 확인을 건너뛰지 않는다. 저장 중 `busy`는 모든 사용자 닫기를 막는다.
139
141
 
140
142
  - 입력 폼 Native Sheet는 `keyboardAvoidance`와 `scrollable`을 함께 켜고, 제품의 키보드 listener·maxHeight·
141
143
  중첩 ScrollView를 지운다. 본문이 FlatList 등 가상화 목록이면 `scrollable={false}`로 둔다.
142
144
  - 저장 버튼은 `footer`에 둔다. 저장 중에는 `busy`로 닫기를 막는다.
143
145
  - 닫힌 뒤 다른 modal을 열거나 화면 이동은 `onDismissComplete`에서 한다. 두 modal을 동시에 띄우지 않는다.
144
- - 문구(`title`, `closeLabel`, 본문)는 i18n 키로 넣는다. 제목 정렬을 위해 `as unknown as string` 캐스트나
146
+ - 문구(`title`, `closeLabel`, 본문)는 i18n 키로 넣는다. 보이지 않는 `title`도 스크린 리더가 읽으므로 번역한다. `as unknown as string` 캐스트나
145
147
  `.hjm-sheet__header` CSS 덮어쓰기를 하지 않는다.
146
148
  - Native `contentStyle`은 배치 key만 쓴다. 높이는 `size`로 정한다(`contentStyle={{ height }}` 금지, 배치 밖 key는 지원하지 않음).
147
149
  - Web Sheet는 `layoutStyle`을 받지 않는다(Web `layoutStyle` 제외 15개 중 하나). 표면 위치·크기는 `placement`·`size`가 정한다.
148
150
 
149
151
  ## 플랫폼 차이
150
152
 
151
- Flutter 제품은 `showModalBottomSheet` 대신 `showHjmBottomSheet`(`hjm_flutter`)로 연다. 같은 기본값(드래그 닫기·손잡이 표시·배경 탭 닫기 끔·× 없음)을 적용하고 본문은 `HjmSheetLayout`을 쓴다(미게시).
153
+ Flutter 제품은 `showModalBottomSheet` 대신 `showHjmBottomSheet`(`hjm_flutter`)로 연다. 같은 기본값(드래그 닫기·손잡이 표시·배경 탭 닫기 끔·× 없음)을 적용하고 본문은 `HjmSheetLayout`을 쓴다(미게시). 2026-10-17의 제목 숨김·가림막 닫기 결정은 아직 Flutter에 반영하지 않았다.
152
154
 
153
155
  | 항목 | Web | Native |
154
156
  | --- | --- | --- |
@@ -158,12 +160,12 @@ Flutter 제품은 `showModalBottomSheet` 대신 `showHjmBottomSheet`(`hjm_flutte
158
160
  | 고정 높이 본문 채우기 | 항상 `flex: 1 1 auto` | `size`가 `auto`가 아니거나 옆 시트일 때 `flexGrow: 1`(1.13.1부터). `scrollable`이면 ScrollView 자체만 늘고 그 안 내용은 내용 높이다 |
159
161
  | 초점 | `initialFocusRef`, `returnFocusRef`, focus trap | `returnFocusRef` |
160
162
  | 겹침 순서 | `modalPriority`, `portalContainer` | RN `Modal` props(`testID` 등) |
161
- | `title` 타입 | `ReactNode` | `string` 또는 element + `accessibilityTitle` |
163
+ | `title` 타입(보이지 않음) | `ReactNode`(시각 숨김 `h2` 안) | `string` 또는 element + `accessibilityTitle`(element는 그리지 않음) |
162
164
 
163
165
  ### GestureSheet(Native optional)
164
166
 
165
167
  - controlled `open`/`onOpenChange(open)`만 있다. `title`·`closeLabel`·`children` 필수, `snapPoints` 기본 `["50%", "90%"]`,
166
- `initialIndex` 기본 `0`, `busy`면 끌어 닫기·backdrop 닫기를 막는다. 닫기 버튼이 본문 끝에 자동으로 들어간다.
168
+ `initialIndex` 기본 `0`, backdrop을 누르면 닫히고 `busy`면 끌어 닫기·backdrop 닫기를 막는다. `title`은 보이지 않는 머리글·접근성 이름이다(미게시). 닫기 버튼이 본문 끝에 자동으로 들어간다.
167
169
  - 화면을 `GestureHandlerRootView` 안의 `GestureSheetProvider`로 감싼다. 입력은 `GestureSheetInput`을 쓴다.
168
170
  - RN `Modal` 안에 GestureSheet를 두면 Android back이 `onRequestClose`로만 간다. 그 host는
169
171
  `dismissTopGestureSheet()`를 먼저 부르고 `false`일 때만 자기 자신을 닫는다.
@@ -180,6 +182,9 @@ Flutter 제품은 `showModalBottomSheet` 대신 `showHjmBottomSheet`(`hjm_flutte
180
182
 
181
183
  ## 근거
182
184
 
185
+ - 시트에 보이는 제목 없음: 2026-10-17 사용자 결정 — 시트 위쪽 제목 줄(예: SearchScreen 필터 시트의 "필터")을 모든 시트에서 없앤다. `title`은 대화상자 접근성 이름으로 남는다. Native Select·Combobox 선택 시트와 GestureSheet도 같다. 같은 작업에서 머리가 손잡이 영역(`.hjm-sheet__drag`, 2026-10-16) 안으로 들어가 `.hjm-sheet > .hjm-sheet__header` 선택자가 맞지 않아 Dialog 위 여백 20이 섞이던 Web 결함을 고쳤다.
186
+ - 가림막 누르면 닫힘: 2026-10-17 사용자 결정 — 하단 시트도 시트 밖을 누르면 닫힌다(2026-10-16의 드래그 전용 기본을 대체). 닫기 경로는 손잡이·드래그와 같은 `requestClose`라 `busy`·`dismissPolicy`·제어형 owner의 확인을 그대로 따른다.
187
+ - 스크롤 영역을 시트 여백 끝까지: 2026-10-17 1.27.2 채택 점검. utilverse 테마·언어 시트(Native `scrollable`)에서 여백 20이 ScrollView 바깥에 있어 ListRow 선택 표면의 양옆이 잘렸다(3x 측정: 선택 테두리 옆 선 없음, 위 선 21–381pt). Web은 본문 스크롤 상자가 이미 여백을 안쪽에 두어 잘림 0(402·1280, LTR·RTL)이었고 Native를 같은 구조로 맞췄다(수정 후 옆 선 13pt·끝 13pt).
183
188
  - Sheet가 제목 정렬·키보드·top inset·maxHeight 보정 소유: 2026-09-23 소비 감사에서 제품마다 Sheet를 직접 조립하며 이 값을 따로 보정하고 있었다.
184
189
  - Native `keyboardAvoidance` 기본 true: 2026-10-16 키보드 전수 조사 — 입력 시트가 옵션을 빠뜨려 키보드에 가려졌다.
185
190
  - 디자인 프로필 상속(radius·floating shadow): 2026-10-07 테마 소비 경로 점검에서 고정 foundation/recipe 값이 남은 곳을 보완했다.
@@ -4,8 +4,8 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-06
8
- - 근거: `src/component-recipes.ts`(`skeletonRecipe`), `src/foundations.ts`(`glyph`)
7
+ - 검토일: 2026-10-17
8
+ - 근거: `src/component-recipes.ts`(`skeletonRecipe`), `src/semantic-colors.ts`(`surface.placeholder`), `src/foundations.ts`(`glyph`·`control`)
9
9
  - 스토리북: `배포/컴포넌트/상태와 알림/스켈레톤`
10
10
 
11
11
  ## 언제 쓰나
@@ -37,27 +37,39 @@
37
37
  // Web
38
38
  import { Skeleton } from "@hjmds/react/feedback";
39
39
 
40
- <>
41
- <Skeleton shape="circle" />
42
- <Skeleton shape="text" width="60%" />
43
- </>
40
+ // 영역에서 한 번만 알린다. Skeleton 자체는 aria-hidden이다.
41
+ <Stack gap="sm" role="status" aria-label={t("profile.loading")} aria-busy="true">
42
+ <Stack axis="inline" gap="sm" align="center">
43
+ <Skeleton shape="circle" />
44
+ <Skeleton shape="text" lines={2} layoutStyle={{ flex: 1 }} />
45
+ </Stack>
46
+ <Skeleton shape="media" />
47
+ </Stack>
44
48
  ```
45
49
 
46
50
  ```tsx
47
51
  // Native
48
52
  import { Skeleton } from "@hjmds/react-native/feedback";
49
53
 
50
- <>
51
- <Skeleton shape="circle" accessibilityLabel={t("profile.loading")} />
52
- <Skeleton shape="text" width="60%" />
53
- </>
54
+ <Stack gap="sm" accessible accessibilityLabel={t("profile.loading")} accessibilityState={{ busy: true }}>
55
+ <Stack axis="inline" gap="sm" align="center">
56
+ <Skeleton shape="circle" />
57
+ <Skeleton shape="text" lines={2} layoutStyle={{ flex: 1 }} />
58
+ </Stack>
59
+ <Skeleton shape="media" />
60
+ </Stack>
54
61
  ```
55
62
 
63
+ 목록 행 하나를 대신할 때는 Skeleton을 조립하지 말고 [ListRow](list-row.md)의 `loading`을 쓴다. 행 높이·슬롯
64
+ 배치를 그대로 유지하고 같은 채움색을 쓴다.
65
+
56
66
  ## 축과 기본값
57
67
 
58
68
  | prop | 값 | 기본값 | 설명 |
59
69
  | --- | --- | --- | --- |
60
- | `shape` | `block`(높이 `spacing.xxl`·radius `md`) · `text`(`spacing.md`·`sm`) · `circle`(`glyph.xxl` 지름·`full`) | `block` | 모양과 기본 크기 |
70
+ | `shape` | `block`(높이 `spacing.xxl` 32·radius `md`) · `text`(`spacing.md` 16·`sm`) · `circle`(`glyph.xxl` 44 지름·`full`) · `media`(폭 100%·16:9·`md`) · `button`(`control.buttonHeight.medium` 44·Button 기본 모양의 모서리 `md`, `buttonShape`로 바꿈) | `block` | 모양과 기본 크기. `media`·`button`은 2026-10-17 추가 |
71
+ | `buttonShape` | `rounded` · `pill`(Button `shape`와 같은 축) | Button 기본값(`rounded`) | `button` 전용. 모서리를 `buttonRecipe.shapes`(rounded `md` · pill `full`)와 테마 radius에서 가져온다. 알약 버튼을 쓰는 제품(번뚝)은 버튼에 준 `shape="pill"`을 그대로 넘긴다(1.28.1) |
72
+ | `lines` | 양의 정수 | `1` | `text` 전용. 줄 수만큼 그리고 마지막 줄은 60%(`skeletonRecipe.lines.lastLineWidthPercent`), 줄 간격 `spacing.xs` 8. 펄스는 묶음 하나로 돈다. `width`는 묶음 전체 폭 |
61
73
  | `animated` | `boolean` | `true`(펄스) | 0.9.12까지는 `false`였다. 정지 블록이 필요하면 `animated={false}`를 명시한다. reduced motion이면 두 renderer 모두 펄스를 멈추고 불투명한 끝 값으로 그린다(recipe `reducedMotion: "static"`) |
62
74
  | `width` | Web `string \| number` · Native `ViewStyle["width"]` | 폭 100%(circle은 지름) | — |
63
75
  | `height` | Web `string \| number` · Native `number` | shape 기본값 | — |
@@ -80,9 +92,10 @@ import { Skeleton } from "@hjmds/react-native/feedback";
80
92
 
81
93
  | 항목 | 값 | 근거 |
82
94
  | --- | --- | --- |
83
- | 크기 | `block` 폭 100% · 높이 `spacing.xxl` 32, `text` 높이 `spacing.md` 16, `circle` 지름 `glyph.xxl` 44. 텍스트 마지막 줄은 `width="60%"`처럼 짧게 | `skeletonRecipe.shapes`, `.hjm-skeleton` |
95
+ | 크기 | `block` 폭 100% · 높이 32, `text` 높이 16, `circle` 지름 44, `media` 폭 100% · 16:9, `button` 높이 44. 여러 줄 본문은 `lines`로(마지막 줄 60%) | `skeletonRecipe.shapes`·`.lines`, `.hjm-skeleton` |
96
+ | 색 | `semanticColors.surface.placeholder`(`text` 12% 투명도) 채움, 테두리 없음. 놓인 면(canvas·Card·프로필 테마)을 기준으로 진해진다 | `skeletonRecipe.background`, `--hjm-skeleton-background` |
84
97
  | 간격 | 실제 콘텐츠의 간격 토큰을 그대로 쓴다(예: [Stack](stack.md) `spacing.xs` 8). Skeleton에 margin을 넣지 않는다 | — |
85
- | 순서·정렬 | 로드 뒤 실제 콘텐츠가 놓일 자리에 같은 순서로. 목록이면 행 구조(원 + 텍스트 줄 둘)를 행 수만큼 반복 | — |
98
+ | 순서·정렬 | 로드 뒤 실제 콘텐츠가 놓일 자리에 같은 순서로. 목록 행은 ListRow `loading`, 카드는 `media` + `text lines={2}` + `button` | — |
86
99
  | 고정·스크롤 | 스크롤 영역 안의 콘텐츠만 대신한다. 고정 바(TopBar·BottomCTA)는 그대로 둔다 | — |
87
100
  | 좁은 폭·큰 글자 | `block`·`text`는 폭 100%라 컨테이너를 따른다. 높이는 글자 크기와 무관한 고정값이다 | `.hjm-skeleton` |
88
101
 
@@ -93,13 +106,13 @@ import { Skeleton } from "@hjmds/react-native/feedback";
93
106
  Native `style`은 제거된 속성이다.
94
107
  - Web Skeleton은 `aria-hidden`이다. 로딩 사실을 알려야 하면 영역 단위로 한 번 알린다
95
108
  (예: 감싼 영역의 `aria-busy`, 또는 Spinner 하나). Skeleton마다 라벨을 붙이지 않는다.
96
- - Web은 `HjmProvider` 안에서 렌더한다. 원 지름·펄스 값을 provider가 내보내는 `--hjm-skeleton-*` 변수로 읽는다.
109
+ - Web은 `HjmProvider` 안에서 렌더한다. 채움색·원 지름·버튼 높이·media 비율·줄 간격·펄스 값을 provider가 내보내는 `--hjm-skeleton-*` 변수로 읽는다.
97
110
 
98
111
  ## 플랫폼 차이
99
112
 
100
113
  | 항목 | Web | Native |
101
114
  | --- | --- | --- |
102
- | 접근성 | 항상 `aria-hidden` | `accessibilityLabel`을 주면 `busy` 상태로 읽힘, 없으면 숨김 |
115
+ | 접근성 | 항상 `aria-hidden` | `accessibilityLabel`을 주면 `busy` 상태로 읽힘, 없으면 숨김(Android `no-hide-descendants`) |
103
116
  | 높이 | `string \| number` | `number`만 |
104
117
  | 모서리 직접 지정 | 없음 | `radius`(0.9 호환용, `shape`보다 우선) |
105
118
  | reduced motion 판단 | `prefers-reduced-motion`·`.hjm-root[data-motion="reduced"]` | provider `environment.reducedMotion` |
@@ -112,3 +125,17 @@ import { Skeleton } from "@hjmds/react-native/feedback";
112
125
  `{...(first ? { accessibilityLabel: t("…") } : {})}`처럼 조건부 spread로 넘긴다.
113
126
  - 현재 Web `style`은 `width`·`height` prop 값 뒤에 펼쳐져 크기·배경을 덮을 수 있다(타입이 막지 않는다). 크기는 `width`·`height`,
114
127
  배치는 `layoutStyle`로 준다.
128
+
129
+ ## 근거
130
+
131
+ - 2026-10-17 사용자 요청("스켈레톤 유아이 개선해줘"): Native 스토리가 흰 바탕 위 옅은 테두리 막대 하나로만 보였다.
132
+ 원인은 `skeletonRecipe.background`가 화면 바탕과 같은 `semanticColors.canvas`였고, 두 renderer가 그 위에 1px
133
+ 테두리를 직접 그린 것이다. 2026-09-29 "넓은 회색·연파랑 면은 canvas + 테두리" 방향을 Skeleton에도 적용했지만,
134
+ 자리표시는 채운 면이어야 모양이 읽히므로 Skeleton·ListRow `loading`을 그 방향의 예외로 둔다.
135
+ - 채움은 불투명 `surfaceAlt` 대신 `text` 12% 투명도다. 대비(바탕 대비, 펄스 최고점): 기본 밝은 테마 약 1.27:1(펄스 최저점 1.14),
136
+ 어두운 테마 약 1.36:1(1.16). 불투명 `surfaceAlt`는 1.23 / 1.20, Card 면(`surface`) 위에서는 1.12 / 1.10이었고, `surfaceAlt`가 `bg`만큼 밝은
137
+ 프로필 팔레트에서는 거의 보이지 않았다. 장식 요소라 텍스트 대비 기준은 적용하지 않는다.
138
+ - `media`·`button`·`lines`는 카드·본문 자리표시를 제품마다 막대로 다시 조립하던 일을 줄이려고 추가했다. 목록 행은
139
+ 기존 ListRow `loading`이 맡으므로 별도 조합 컴포넌트는 두지 않는다.
140
+ - 움직임은 기존 불투명도 펄스(0.56↔1)를 유지한다. 빛 띠(shimmer)는 Native에 그라데이션 peer가 필요하고 reduced
141
+ motion에서는 어차피 정지해야 한다.
@@ -93,7 +93,7 @@ Native 프레임의 모서리는 `statisticRecipe.presentations`의 역할을 Pr
93
93
  | 항목 | 값 | 근거 |
94
94
  | --- | --- | --- |
95
95
  | 크기 | `presentation="surface"`의 안쪽 여백: `comfortable` `spacing.md` 16 · `compact` `spacing.sm` 12(두 플랫폼). 테두리 `stroke.default` 1, radius `md` 12 | `statisticRecipe.density`·`presentations`, `styles.css` `.hjm-statistic[data-presentation="surface"]` |
96
- | 글자 | 라벨 `label` 12 semibold(`compact` `caption` 11), 값 `heading` 24 heavy(`compact` `title` 18), prefix·suffix `body` 14 semibold 본문색, hint `caption`, 추세 `caption` bold(두 플랫폼). 2026-10-06까지 Web은 라벨 본문 14 medium, 값은 밀도와 관계없이 18, prefix·suffix는 흐린색이었다(1.13.0 변경) | `statisticRecipe.density`·`label`·`value`·`affix`·`hint`·`trend`, `.hjm-statistic__*` |
96
+ | 글자 | 라벨 `label` 12 medium(`compact` `caption` 11), 값 `heading` 24 semibold(`fontWeight.semibold`, `compact` `title` 18. 2026-10-17 큰 글자 단계 폐지로 `heading.level2` 굵기 참조를 끊음), prefix·suffix `body` 14 medium 본문색, hint `caption`, 추세 `caption` medium(두 플랫폼). 2026-10-06까지 Web은 라벨 본문 14 medium, 값은 밀도와 관계없이 18, prefix·suffix는 흐린색이었다(1.13.0 변경) | `statisticRecipe.density`·`label`·`value`·`affix`·`hint`·`trend`, `.hjm-statistic__*` |
97
97
  | 간격 | 그룹 간격 `statisticRecipe.group.gap` = `spacing.xs` 8. 지표 안 줄 간격(라벨·값·추세) `comfortable` `spacing.xs` 8 · `compact` `spacing.xxs` 4(두 플랫폼) | `styles.css` `.hjm-statistic`·`.hjm-statistic-group`, `statisticRecipe` |
98
98
  | 순서·정렬 | 화면 위쪽 요약 영역이나 카드 안에 둔다. 지표 여러 개는 [Grid](grid.md)를 직접 짜지 말고 `StatisticGroup`으로 묶는다 | — |
99
99
  | 고정·스크롤 | — | — |
@@ -135,3 +135,7 @@ Native 프레임의 모서리는 `statisticRecipe.presentations`의 역할을 Pr
135
135
  양쪽 움직이는 수치 Storybook에 소수와 음수(de-DE), 비라틴 숫자(ar-EG), 지수 표기(en-US),
136
136
  동작 줄이기와 RTL 비교를 제공한다. 소수·음수 예제는 기록 개수가 아닌 측정값이다.
137
137
  Web은 자리 단위 전환, Native는 지표 전체 전환이며 같은 시각 효과를 보장하지 않는다.
138
+
139
+ ## 근거
140
+
141
+ 2026-10-17 사용자 결정("굵기 좀더 줄여"): 모든 글자 굵기를 한 단계 낮췄다. 큰 숫자·선택 상태 600(큰 제목 단계는 같은 날 폐지), 제목·강조 500, label 500, 본문 400이며 정본은 [타이포그래피](../tokens/typography.md)다.
@@ -77,8 +77,8 @@ import { Switch } from "@hjmds/react-native/inputs";
77
77
 
78
78
  | 항목 | 값 | 근거 |
79
79
  | --- | --- | --- |
80
- | 크기 | 트랙 `small` 44×26(thumb 22) · `medium` 52×32(thumb 28), 안쪽 여백 2. iOS Native는 `UISwitch` 고유 크기. 루트 최소 높이 44(`control.minTouchTarget`). 행 높이는 설명이 없으면 44, Native는 설명이 있으면 68(`rowTwoLineMinHeight`, ListRow 두 줄 행과 같다). Web은 68을 고정하지 않고 내용 높이를 따른다 | `switchRecipe.sizes`·`rowMinHeight`·`rowTwoLineMinHeight`, `styles.css` `.hjm-switch`, `react-native/src/inputs.tsx` |
81
- | 간격 | 이름과 트랙 사이 `spacing.sm` 12, 이름과 설명 사이 `spacing.xxs` 4. 여러 개를 쌓을 때는 [List](list.md)·[ListRow](list-row.md) 행 구분을 따르고 Switch 사이에 여백을 더하지 않는다 | `styles.css` `.hjm-switch`·`.hjm-switch__copy`, `react-native/src/inputs.tsx` |
80
+ | 크기 | 트랙 `small` 44×26(thumb 22) · `medium` 52×32(thumb 28), 안쪽 여백 2. iOS Native는 `UISwitch` 고유 크기. 루트 최소 높이 44(`control.minTouchTarget`). 행 높이는 설명이 없으면 44. 설명이 보이는 행은 위아래 안쪽 여백 8(`rowPaddingVertical`, ListRow comfortable과 같다)에 최소 56(`rowTwoLineMinHeight` = 이름 20 + 4 + 설명 16 + 8×2). Web·Native 같다. `labelVisibility="hidden"`이면 설명이 있어도 44 | `switchRecipe.sizes`·`rowMinHeight`·`rowPaddingVertical`·`rowTwoLineMinHeight`, `styles.css` `.hjm-switch[data-two-line]`, `react-native/src/inputs.tsx` |
81
+ | 간격 | 이름과 트랙 사이 `spacing.sm` 12, 이름과 설명 사이 `spacing.xxs` 4. 여러 개를 쌓을 때는 [List](list.md)(`separator`로 구분선 선택)에 넣고 Switch 사이에 Stack gap 같은 여백을 더하지 않는다. 행 간격은 행의 최소 높이·안쪽 여백이 맡는다(설명 있는 행 간격 56) | `styles.css` `.hjm-switch`·`.hjm-switch__copy`, `react-native/src/inputs.tsx` |
82
82
  | 순서·정렬 | 설정 목록에서는 `presentation="row"`로 행 전체 폭을 쓰고 이름·설명이 앞(왼쪽), 트랙이 끝(오른쪽) | `styles.css` `.hjm-switch[data-presentation="row"]` |
83
83
  | 고정·스크롤 | — | — |
84
84
  | 좁은 폭·큰 글자 | 글자 배율 ≥ 1.6(`largeTextThreshold`)이면 `row`는 이름·설명 아래로 트랙을 내린다 | `switchRecipe.stackedTextScale`, `styles.css` `.hjm-root[data-large-text="true"]` |
@@ -130,3 +130,4 @@ presentation="row" 큰 글자(≥1.6)
130
130
  ## 근거
131
131
 
132
132
  - 기본 `row` 통일: 2026-10-17 웹·앱 비교 도구에서 같은 설정 스위치가 Web은 트랙 먼저(inline), Native는 이름 먼저(row)로 거울처럼 그려졌다.
133
+ - 설명 있는 행 56·여백 8, 쌓을 때 gap 없음: 2026-10-17 사용자 보고(알림 설정 "간격이 커") — 글자 40에 ListRow 두 줄 68을 빌린 최소 높이와 미리보기의 Stack gap 16이 겹쳐 행 간격이 84였다.