@hjmds/design-contracts 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/dist/avatar-fallback.d.ts +11 -0
  2. package/dist/avatar-fallback.d.ts.map +1 -1
  3. package/dist/avatar-fallback.js +21 -0
  4. package/dist/avatar-fallback.js.map +1 -1
  5. package/dist/base-recipes.d.ts +17 -0
  6. package/dist/base-recipes.d.ts.map +1 -1
  7. package/dist/base-recipes.js +17 -0
  8. package/dist/base-recipes.js.map +1 -1
  9. package/dist/catalog.d.ts +25 -0
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/command-palette.d.ts +14 -9
  12. package/dist/command-palette.d.ts.map +1 -1
  13. package/dist/command-palette.js +8 -9
  14. package/dist/command-palette.js.map +1 -1
  15. package/dist/component-recipes.d.ts +17 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +5 -0
  18. package/dist/component-recipes.js.map +1 -1
  19. package/dist/provider-button.d.ts.map +1 -1
  20. package/dist/provider-button.js +3 -0
  21. package/dist/provider-button.js.map +1 -1
  22. package/dist/reactions.d.ts +10 -0
  23. package/dist/reactions.d.ts.map +1 -1
  24. package/dist/reactions.js +7 -0
  25. package/dist/reactions.js.map +1 -1
  26. package/dist/screen-patterns.d.ts +147 -0
  27. package/dist/screen-patterns.d.ts.map +1 -0
  28. package/dist/screen-patterns.js +149 -0
  29. package/dist/screen-patterns.js.map +1 -0
  30. package/dist/slider.d.ts +8 -0
  31. package/dist/slider.d.ts.map +1 -1
  32. package/dist/slider.js +6 -1
  33. package/dist/slider.js.map +1 -1
  34. package/dist/upload-item.d.ts +5 -0
  35. package/dist/upload-item.d.ts.map +1 -1
  36. package/dist/upload-item.js +5 -0
  37. package/dist/upload-item.js.map +1 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/dist/version.js.map +1 -1
  41. package/docs/action-session.md +3 -3
  42. package/docs/agreement.md +5 -0
  43. package/docs/avatar-fallback.md +7 -0
  44. package/docs/bottom-navigation.md +6 -0
  45. package/docs/brand-boundary.md +1 -1
  46. package/docs/button-label.md +6 -0
  47. package/docs/clipboard.md +3 -0
  48. package/docs/command-palette.md +45 -2
  49. package/docs/consumer-policy.md +5 -1
  50. package/docs/data-table.md +6 -4
  51. package/docs/dialog.md +8 -2
  52. package/docs/form.md +51 -0
  53. package/docs/generated/component-maturity.md +1 -1
  54. package/docs/generated/renderer-evidence.json +3 -3
  55. package/docs/generated/renderer-evidence.md +1 -1
  56. package/docs/generated/showcase-manifest.json +1 -1
  57. package/docs/link.md +8 -0
  58. package/docs/migration-native-legacy-removal.md +45 -1
  59. package/docs/optional-adapters.md +1 -1
  60. package/docs/password-field.md +5 -0
  61. package/docs/product-composition-adoption.md +40 -0
  62. package/docs/progress.md +19 -1
  63. package/docs/provider-button.md +13 -0
  64. package/docs/result.md +3 -0
  65. package/docs/screen-chrome.md +10 -0
  66. package/docs/screen-patterns.md +376 -0
  67. package/docs/sheet.md +12 -0
  68. package/docs/splitter.md +8 -2
  69. package/docs/theming.md +36 -29
  70. package/docs/toggle-group.md +7 -0
  71. package/docs/tour.md +7 -1
  72. package/docs/tree.md +5 -2
  73. package/docs/upload-item.md +7 -0
  74. package/docs/usage/README.md +236 -0
  75. package/docs/usage/STANDARD.md +108 -0
  76. package/docs/usage/components/accordion.md +107 -0
  77. package/docs/usage/components/activity-heatmap.md +104 -0
  78. package/docs/usage/components/affix.md +86 -0
  79. package/docs/usage/components/agreement.md +129 -0
  80. package/docs/usage/components/alert-dialog.md +130 -0
  81. package/docs/usage/components/anchor.md +96 -0
  82. package/docs/usage/components/aspect-ratio.md +89 -0
  83. package/docs/usage/components/asset.md +126 -0
  84. package/docs/usage/components/auth-provider-button.md +116 -0
  85. package/docs/usage/components/auth-screen-layout.md +129 -0
  86. package/docs/usage/components/avatar.md +114 -0
  87. package/docs/usage/components/badge.md +84 -0
  88. package/docs/usage/components/bottom-cta.md +125 -0
  89. package/docs/usage/components/bottom-info.md +99 -0
  90. package/docs/usage/components/bottom-navigation.md +136 -0
  91. package/docs/usage/components/breadcrumb.md +81 -0
  92. package/docs/usage/components/button.md +118 -0
  93. package/docs/usage/components/calendar.md +122 -0
  94. package/docs/usage/components/card.md +110 -0
  95. package/docs/usage/components/carousel.md +113 -0
  96. package/docs/usage/components/celebration.md +96 -0
  97. package/docs/usage/components/chat-message.md +122 -0
  98. package/docs/usage/components/chat-screen.md +112 -0
  99. package/docs/usage/components/checkbox-group.md +104 -0
  100. package/docs/usage/components/checkbox.md +103 -0
  101. package/docs/usage/components/chip.md +104 -0
  102. package/docs/usage/components/code-block.md +111 -0
  103. package/docs/usage/components/collapsible.md +112 -0
  104. package/docs/usage/components/color-picker.md +86 -0
  105. package/docs/usage/components/combobox.md +137 -0
  106. package/docs/usage/components/command-palette.md +125 -0
  107. package/docs/usage/components/comment-thread-screen.md +125 -0
  108. package/docs/usage/components/container.md +98 -0
  109. package/docs/usage/components/content-transition.md +101 -0
  110. package/docs/usage/components/context-menu.md +136 -0
  111. package/docs/usage/components/counter-badge.md +107 -0
  112. package/docs/usage/components/data-table.md +122 -0
  113. package/docs/usage/components/date-picker.md +142 -0
  114. package/docs/usage/components/date-range-picker.md +111 -0
  115. package/docs/usage/components/description-list.md +103 -0
  116. package/docs/usage/components/design-system-provider.md +124 -0
  117. package/docs/usage/components/dialog.md +176 -0
  118. package/docs/usage/components/divider.md +89 -0
  119. package/docs/usage/components/editor-screen.md +126 -0
  120. package/docs/usage/components/effect-surface.md +120 -0
  121. package/docs/usage/components/empty-state.md +114 -0
  122. package/docs/usage/components/field.md +129 -0
  123. package/docs/usage/components/file-picker.md +114 -0
  124. package/docs/usage/components/floating-action-button.md +138 -0
  125. package/docs/usage/components/form.md +162 -0
  126. package/docs/usage/components/grid.md +99 -0
  127. package/docs/usage/components/heading.md +87 -0
  128. package/docs/usage/components/icon-button.md +126 -0
  129. package/docs/usage/components/icon.md +105 -0
  130. package/docs/usage/components/image.md +122 -0
  131. package/docs/usage/components/keyboard-avoiding.md +93 -0
  132. package/docs/usage/components/keyboard-dock.md +110 -0
  133. package/docs/usage/components/keyboard-form-scroll-view.md +95 -0
  134. package/docs/usage/components/keyboard-motion-provider.md +86 -0
  135. package/docs/usage/components/layout.md +117 -0
  136. package/docs/usage/components/link.md +121 -0
  137. package/docs/usage/components/list-detail-screen.md +103 -0
  138. package/docs/usage/components/list-row.md +124 -0
  139. package/docs/usage/components/list.md +119 -0
  140. package/docs/usage/components/load-more.md +115 -0
  141. package/docs/usage/components/masonry.md +109 -0
  142. package/docs/usage/components/media-selection-screen.md +119 -0
  143. package/docs/usage/components/mentions.md +119 -0
  144. package/docs/usage/components/menu.md +129 -0
  145. package/docs/usage/components/menubar.md +93 -0
  146. package/docs/usage/components/message-composer.md +124 -0
  147. package/docs/usage/components/moderation-screen.md +113 -0
  148. package/docs/usage/components/notice.md +106 -0
  149. package/docs/usage/components/notification-inbox-screen.md +97 -0
  150. package/docs/usage/components/notification-item.md +98 -0
  151. package/docs/usage/components/number-field.md +131 -0
  152. package/docs/usage/components/onboarding-screen.md +106 -0
  153. package/docs/usage/components/otp-field.md +101 -0
  154. package/docs/usage/components/pagination.md +82 -0
  155. package/docs/usage/components/password-field.md +137 -0
  156. package/docs/usage/components/permission-screen.md +107 -0
  157. package/docs/usage/components/photo-source-sheet.md +119 -0
  158. package/docs/usage/components/popover.md +108 -0
  159. package/docs/usage/components/profile-screen.md +89 -0
  160. package/docs/usage/components/progress.md +122 -0
  161. package/docs/usage/components/qr-code.md +122 -0
  162. package/docs/usage/components/radio-group.md +124 -0
  163. package/docs/usage/components/radio.md +104 -0
  164. package/docs/usage/components/result.md +116 -0
  165. package/docs/usage/components/saved-items-screen.md +126 -0
  166. package/docs/usage/components/screen-layout.md +119 -0
  167. package/docs/usage/components/search-field.md +120 -0
  168. package/docs/usage/components/search-screen.md +215 -0
  169. package/docs/usage/components/section.md +111 -0
  170. package/docs/usage/components/segmented-control.md +138 -0
  171. package/docs/usage/components/select.md +142 -0
  172. package/docs/usage/components/settings-screen.md +126 -0
  173. package/docs/usage/components/shared-transition-element.md +111 -0
  174. package/docs/usage/components/shared-transition-screen.md +86 -0
  175. package/docs/usage/components/sheet.md +151 -0
  176. package/docs/usage/components/side-panel.md +104 -0
  177. package/docs/usage/components/sidebar.md +107 -0
  178. package/docs/usage/components/skeleton.md +105 -0
  179. package/docs/usage/components/skip-nav.md +76 -0
  180. package/docs/usage/components/slider.md +121 -0
  181. package/docs/usage/components/sortable-collection.md +127 -0
  182. package/docs/usage/components/spinner.md +86 -0
  183. package/docs/usage/components/splitter.md +103 -0
  184. package/docs/usage/components/stack.md +93 -0
  185. package/docs/usage/components/statistic.md +123 -0
  186. package/docs/usage/components/steps.md +110 -0
  187. package/docs/usage/components/surface.md +91 -0
  188. package/docs/usage/components/swipe-actions.md +124 -0
  189. package/docs/usage/components/switch.md +120 -0
  190. package/docs/usage/components/tabs.md +134 -0
  191. package/docs/usage/components/tag.md +84 -0
  192. package/docs/usage/components/tags-input.md +111 -0
  193. package/docs/usage/components/text-area.md +112 -0
  194. package/docs/usage/components/text-format.md +75 -0
  195. package/docs/usage/components/text-transition.md +104 -0
  196. package/docs/usage/components/text.md +101 -0
  197. package/docs/usage/components/thinking-orb.md +105 -0
  198. package/docs/usage/components/timeline.md +105 -0
  199. package/docs/usage/components/toast.md +145 -0
  200. package/docs/usage/components/toggle-group.md +95 -0
  201. package/docs/usage/components/tooltip.md +103 -0
  202. package/docs/usage/components/top-bar.md +124 -0
  203. package/docs/usage/components/top.md +89 -0
  204. package/docs/usage/components/tour.md +118 -0
  205. package/docs/usage/components/transfer-list.md +115 -0
  206. package/docs/usage/components/tree.md +91 -0
  207. package/docs/usage/components/upload-item.md +99 -0
  208. package/docs/usage/components/virtual-list.md +105 -0
  209. package/docs/usage/components/visually-hidden.md +72 -0
  210. package/docs/usage/components/watermark.md +78 -0
  211. package/docs/usage/compositions/action-recovery-optimistic.md +180 -0
  212. package/docs/usage/compositions/action-recovery-save.md +235 -0
  213. package/docs/usage/compositions/action-recovery-undo.md +193 -0
  214. package/docs/usage/compositions/common-message.md +132 -0
  215. package/docs/usage/compositions/common-notification.md +101 -0
  216. package/docs/usage/compositions/compound-controls.md +186 -0
  217. package/docs/usage/compositions/data-layouts.md +157 -0
  218. package/docs/usage/compositions/disclosure.md +144 -0
  219. package/docs/usage/compositions/environment-matrix.md +139 -0
  220. package/docs/usage/compositions/expo-interactions.md +149 -0
  221. package/docs/usage/compositions/family-drawer.md +201 -0
  222. package/docs/usage/compositions/floating-action-button.md +197 -0
  223. package/docs/usage/compositions/input-sheet.md +148 -0
  224. package/docs/usage/compositions/interaction-adapters.md +190 -0
  225. package/docs/usage/compositions/interaction-flow-apply.md +205 -0
  226. package/docs/usage/compositions/interaction-flow-draft.md +188 -0
  227. package/docs/usage/compositions/interaction-flow-search.md +171 -0
  228. package/docs/usage/compositions/native-renderers.md +106 -0
  229. package/docs/usage/compositions/navigation-bar-collection.md +164 -0
  230. package/docs/usage/compositions/optional-adapters.md +169 -0
  231. package/docs/usage/compositions/optional-motion.md +109 -0
  232. package/docs/usage/compositions/photo-source.md +104 -0
  233. package/docs/usage/compositions/purpose-input-comment.md +110 -0
  234. package/docs/usage/compositions/purpose-input-message.md +119 -0
  235. package/docs/usage/compositions/reference-first.md +96 -0
  236. package/docs/usage/compositions/reference-review.md +107 -0
  237. package/docs/usage/compositions/reference-settings.md +107 -0
  238. package/docs/usage/compositions/selection-scope.md +174 -0
  239. package/docs/usage/compositions/stea-event-ticket.md +166 -0
  240. package/docs/usage/compositions/stea-flip-card.md +162 -0
  241. package/docs/usage/compositions/stea-order-progress.md +184 -0
  242. package/docs/usage/compositions/stea-otp-verify.md +215 -0
  243. package/docs/usage/compositions/stea-pixel-empty.md +140 -0
  244. package/docs/usage/compositions/stea-schedule-card.md +169 -0
  245. package/docs/usage/compositions/stea-stat-summary.md +154 -0
  246. package/docs/usage/compositions/time-selection.md +174 -0
  247. package/docs/usage/compositions/toast-layout.md +128 -0
  248. package/docs/usage/compositions/visual-foundations.md +185 -0
  249. package/docs/usage/compositions/web-additions.md +146 -0
  250. package/docs/usage/compositions/web-navigation.md +143 -0
  251. package/docs/usage/screens/common-chat.md +127 -0
  252. package/docs/usage/screens/common-comments.md +108 -0
  253. package/docs/usage/screens/common-inbox.md +110 -0
  254. package/docs/usage/screens/common-login.md +98 -0
  255. package/docs/usage/screens/common-profile.md +221 -0
  256. package/docs/usage/screens/common-saved.md +127 -0
  257. package/docs/usage/screens/common-search.md +274 -0
  258. package/docs/usage/screens/common-settings.md +126 -0
  259. package/docs/usage/screens/common-shell.md +108 -0
  260. package/docs/usage/screens/dashboard.md +245 -0
  261. package/docs/usage/screens/discovery-gallery.md +306 -0
  262. package/docs/usage/screens/flow-collection.md +96 -0
  263. package/docs/usage/screens/flow-editor.md +120 -0
  264. package/docs/usage/screens/flow-media.md +111 -0
  265. package/docs/usage/screens/flow-moderation.md +120 -0
  266. package/docs/usage/screens/flow-onboarding.md +193 -0
  267. package/docs/usage/screens/flow-permission.md +103 -0
  268. package/docs/usage/screens/landing.md +347 -0
  269. package/docs/usage/screens/mockup-studio.md +190 -0
  270. package/docs/usage/screens/notification-settings.md +206 -0
  271. package/docs/usage/screens/reference-comparison.md +159 -0
  272. package/docs/usage/templates/component.md +61 -0
  273. package/docs/usage/templates/composition.md +47 -0
  274. package/docs/usage/templates/screen.md +56 -0
  275. package/docs/usage/templates/token.md +32 -0
  276. package/docs/usage/tokens/color.md +142 -0
  277. package/docs/usage/tokens/elevation-opacity.md +86 -0
  278. package/docs/usage/tokens/layers.md +98 -0
  279. package/docs/usage/tokens/layout.md +114 -0
  280. package/docs/usage/tokens/motion.md +88 -0
  281. package/docs/usage/tokens/radius.md +53 -0
  282. package/docs/usage/tokens/size.md +74 -0
  283. package/docs/usage/tokens/spacing.md +73 -0
  284. package/docs/usage/tokens/stroke.md +50 -0
  285. package/docs/usage/tokens/theme-studio.md +70 -0
  286. package/docs/usage/tokens/typography-studio.md +70 -0
  287. package/docs/usage/tokens/typography.md +89 -0
  288. package/package.json +7 -1
@@ -0,0 +1,184 @@
1
+ # 처리 단계와 재시도
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [Steps](../../steps.md), [Timeline](../../timeline.md), `showcase/web/src/patterns/stea-composition-previews.tsx`(`OrderProgressRetry`), `showcase/native/src/stea-composition-previews.tsx`(`OrderProgressRetry`, `Frame`), `showcase/shared/stea-compositions.ts`(`orderReducer`, `orderStepsDescriptor`), `src/steps.ts`, `src/container.ts`
9
+ - 스토리북: `배포/구성/피드백과 복구/처리 단계와 재시도`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 주문·신청처럼 서버가 단계를 하나씩 확정하는 처리 과정을 보여 주고, 확정에 실패하면 같은 단계를 다시 요청하게 할 때 쓴다.
14
+ 다음 단계는 서버가 확정한 뒤에만 완료로 바뀌고, 실패는 거절된 단계에 오류 표시만 남긴다. 사용자가 직접 입력하며
15
+ 앞뒤로 오가는 마법사는 [Steps](../components/steps.md) 단독이나 `StepPlayer`를 쓴다.
16
+
17
+ ## 구성 요소
18
+
19
+ | 컴포넌트 | 역할 | 지침 |
20
+ | --- | --- | --- |
21
+ | `Card` | 제목("주문 처리 단계")·설명 틀 | [Card](../components/card.md) |
22
+ | `Steps` | 가로 단계 표시. `currentStepStatus`: `current` · `error` · `complete` | [Steps](../components/steps.md) |
23
+ | 진행 문구 `Text` | "서버 확인을 기다리는 중이에요." / "모든 단계가 확정됐어요." | [Text](../components/text.md) |
24
+ | `Notice` `tone="danger"` | 실패 안내(상태는 그대로이니 다시 요청) | [Notice](../components/notice.md) |
25
+ | `Button` primary | 다음 단계 요청 / 다시 요청. 요청 중 `loading` | [Button](../components/button.md) |
26
+ | `Button` secondary | 전체 완료 후 "처음부터 다시" | [Button](../components/button.md) |
27
+ | `Text` label muted + `Timeline` | "처리 기록" 제목과 확정·실패 사건 목록 | [Timeline](../components/timeline.md) |
28
+ | `Switch` | 스토리 전용 "다음 요청을 실패로 응답". 제품에 넣지 않는다 | [Switch](../components/switch.md) |
29
+ | `ScrollView` + `Container`(Native) | 바깥 틀. 세로 스크롤·위아래 여백, 좌우 gutter | [Container](../components/container.md), [화면 여백과 너비](../tokens/layout.md) |
30
+
31
+ ## 배치
32
+
33
+ ```text
34
+ Native 화면(ScrollView, 위아래 spacing.lg 20) > Container gutter 16(폭 < 600) · 20(폭 ≥ 600)
35
+ ┌ Card ────────────────────────────────────────┐ body padding spacing.md 16
36
+ │ 주문 처리 단계 (title) │
37
+ │ 다음 단계는 서버가 확정한 뒤에만… (muted) │
38
+ │ (✓)──(2)──(3)──(4)──(5) ← Steps │
39
+ │ 접수 결제확인 상품준비 발송 도착 │
40
+ │ ↕ spacing.lg 20 │
41
+ │ 서버 확인을 기다리는 중이에요. ← 진행 문구 │
42
+ │ ↕ spacing.lg 20 │
43
+ │ ┌ ! 서버가 이 단계를 확정하지 못했어요… ┐ │ ← 실패 때만 Notice danger
44
+ │ └───────────────────────────────────────┘ │
45
+ │ ↕ spacing.lg 20 │
46
+ │ [ 다시 요청 ] │ ← 주 행동(primary)
47
+ │ ↕ spacing.lg 20 │
48
+ │ 처리 기록 (label muted) │
49
+ │ ↕ spacing.lg 20 │
50
+ │ ● 접수 · 주문을 받았어요. ← Timeline │
51
+ │ ● 결제 확인 실패 · 단계는 바뀌지 않았어요. │
52
+ └──────────────────────────────────────────────┘
53
+ 고정 영역 없음. 기록이 길어지면 화면 스크롤로 내려간다. 안전 영역은 화면 골격이 맡는다
54
+ ```
55
+
56
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
57
+ | --- | --- | --- | --- |
58
+ | 바깥 틀 | Web: 제품 화면 레이아웃(문서 스크롤). Native: `ScrollView` > `Container` | 이 구성은 바깥 폭·여백을 정하지 않는다(스토리는 Card만 그린다). Native는 Card를 `ScrollView` 안 [Container](../components/container.md)에 둔다. 입력이 없어 키보드 처리는 없다. 안전 영역은 화면 골격(내비게이션 헤더·탭 바)이 맡는다 | Native ScrollView 위아래 `spacing.lg` 20, Container gutter 폭 600 미만 `compact` 16 · 이상 `regular` 20(`layout.pagePadding`) |
59
+ | 틀 | `Card` | 바깥 틀 안, 스크롤과 함께 | body padding `spacing.md` 16, 안쪽 `Stack gap="lg"` 20 |
60
+ | 단계 | `Steps` | Card 머리 아래 맨 위 | 단계 사이 `stepsRecipe.gap` `spacing.xs` 8, 표시 원 `glyph.md` |
61
+ | 진행 | 진행 문구 | 단계 아래 | `spacing.lg` 20 |
62
+ | 실패 | `Notice` danger | 진행 문구 아래, 실패 때만 | `spacing.lg` 20 |
63
+ | 행동 | `Button` primary(또는 완료 후 secondary) | Notice 아래, 꽉 찬 폭(`Stack` 기본 `align="stretch"`가 채우므로 Web `layoutStyle`·Native `fullWidth`를 따로 주지 않는다) | 높이 44, 행동 묶음 안 `Stack gap="sm"` 12 |
64
+ | 기록 | label + `Timeline` | 맨 아래, 스크롤 | `spacing.lg` 20, 기록은 아래로 쌓인다 |
65
+
66
+ - 실패 Notice는 버튼 **위**에 둔다. 사용자가 이유를 읽은 다음 "다시 요청"에 닿는다.
67
+ - 기록이 길어지면 Card 밖 화면 스크롤로 내려간다. 단계·행동은 위에 남는다(고정은 아님).
68
+
69
+ ## 흐름과 상태
70
+
71
+ 1. 첫 단계("접수")가 확정된 채 열리고 커서는 다음 단계("결제 확인")에 있다.
72
+ 2. "다음 단계 요청"을 누르면 요청 중이 된다(버튼 `loading`, 진행 문구).
73
+ 3. 서버가 확정하면 커서가 다음 단계로 가고 기록에 success 항목이 붙는다.
74
+ 4. 서버가 거절하면 커서는 그대로, 그 단계에 `error` 표시, danger Notice, 기록에 attention 항목. 버튼 라벨이 "다시 요청"이 된다.
75
+ 5. 네트워크·서버 오류로 판정을 받지 못하면 단계 표시는 그대로 두고 Notice로 알린 뒤 "다시 요청"을 받는다.
76
+ 6. 마지막 단계까지 확정되면 `currentStepStatus="complete"`로 전체 완료를 표시하고 버튼이 secondary "처음부터 다시"로 바뀐다.
77
+
78
+ | 상태 | 모습 | 포커스·알림 |
79
+ | --- | --- | --- |
80
+ | 기본 | Steps `current`, 버튼 "다음 단계 요청" | — |
81
+ | 진행 중 | 버튼 `loading`, 진행 문구 | 진행 문구가 Web `role="status"`, Native live region으로 읽힌다. 포커스는 버튼 유지 |
82
+ | 실패 | 서버 거절: 거절된 단계 `error`(라벨 "확인 필요"), Notice danger, 버튼 "다시 요청", 기록에 attention 항목 | Notice가 알린다(Web `danger`는 `role="alert"`, Native는 `announcement="assertive"`를 줘야 발표된다) |
83
+ | 요청 실패(네트워크·서버 오류) | 커서·단계 표시는 그대로(`current`), Notice danger에 연결 실패 문구, 버튼 "다시 요청". 서버가 판정하지 않았으므로 기록에 사건을 남기지 않는다 | 실패와 같다 |
84
+ | 완료 | 모든 단계 complete, 진행 문구 "모든 단계가 확정됐어요.", secondary 버튼 | 진행 문구가 읽힌다 |
85
+
86
+ - 요청 중 중복 요청은 상태 로직에서 무시한다. 버튼 `loading`만으로는 키보드 반복 입력을 막지 못한다.
87
+ - 문구 키는 상태별 상수로 둔다. 상태 이름으로 키를 조립하지 않는다.
88
+
89
+ | 상태 | 진행 문구 키 | Notice 키 | 버튼 키 |
90
+ | --- | --- | --- | --- |
91
+ | 기본 | — | — | `order.request` |
92
+ | 진행 중 | `order.requesting` | — | `order.request`(`loading`) |
93
+ | 실패(거절) | — | `order.failed` | `order.retry` |
94
+ | 요청 실패 | — | `order.requestFailed` | `order.retry` |
95
+ | 완료 | `order.done` | — | `order.restart` |
96
+
97
+ ## 코드 골격
98
+
99
+ ```tsx
100
+ // Web
101
+ import { Button } from "@hjmds/react/actions";
102
+ import { Card, Timeline } from "@hjmds/react/display";
103
+ import { Notice } from "@hjmds/react/feedback";
104
+ import { Stack, Text } from "@hjmds/react/layout";
105
+ import { Steps } from "@hjmds/react/steps";
106
+
107
+ // 상태 → 문구 키
108
+ const noticeKey = { rejected: "order.failed", requestFailed: "order.requestFailed" } as const;
109
+ const progressKey = { requesting: "order.requesting", done: "order.done" } as const;
110
+
111
+ <Card title={t("order.title")} description={t("order.description")}>
112
+ <Stack gap="lg">
113
+ <Steps descriptor={{ steps, currentStepId, currentStepStatus }} statusLabels={statusLabels}
114
+ composeAccessibleName={({ position, total, label }) => t("order.stepName", { position, total, label })} />
115
+ {/* 알림 자리를 위해 status 영역은 늘 마운트한다 */}
116
+ <Text role="status">{requesting ? t(progressKey.requesting) : done ? t(progressKey.done) : ""}</Text>
117
+ {failure ? <Notice tone="danger" title={t(noticeKey[failure])} /> : null}
118
+ <Stack gap="sm">
119
+ {done
120
+ ? <Button tone="secondary" onClick={restart}>{t("order.restart")}</Button>
121
+ : <Button loading={requesting} onClick={request}>{failure ? t("order.retry") : t("order.request")}</Button>}
122
+ </Stack>
123
+ <Text variant="label" tone="muted">{t("order.log")}</Text>
124
+ <Timeline items={log} composeAccessibleName={({ position, total, label }) => t("order.logName", { position, total, label })} />
125
+ </Stack>
126
+ </Card>;
127
+ ```
128
+
129
+ ```tsx
130
+ // Native
131
+ import { ScrollView, useWindowDimensions } from "react-native";
132
+ import { spacing } from "@hjmds/design-contracts/foundations";
133
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
134
+ import { Button } from "@hjmds/react-native/actions";
135
+ import { Card, Timeline } from "@hjmds/react-native/data-display";
136
+ import { Notice } from "@hjmds/react-native/feedback";
137
+ import { Container, Stack, Text } from "@hjmds/react-native/primitives";
138
+ import { Steps } from "@hjmds/react-native/steps";
139
+
140
+ const noticeKey = { rejected: "order.failed", requestFailed: "order.requestFailed" } as const;
141
+ const { width } = useWindowDimensions();
142
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
143
+
144
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
145
+ <Container gutter={gutter}>
146
+ <Card title={t("order.title")} description={t("order.description")}>
147
+ <Stack gap="lg">
148
+ <Steps descriptor={{ steps, currentStepId, currentStepStatus }} statusLabels={statusLabels}
149
+ composeAccessibleName={({ position, total, label }) => t("order.stepName", { position, total, label })} />
150
+ {/* 빈 Text가 gap 하나만큼 틈을 남기므로 문구가 있을 때만 그린다. */}
151
+ {requesting || done ? <Text accessibilityLiveRegion="polite">{done ? t("order.done") : t("order.requesting")}</Text> : null}
152
+ {failure ? <Notice tone="danger" announcement="assertive" title={t(noticeKey[failure])} /> : null}
153
+ <Stack gap="sm">
154
+ {done
155
+ ? <Button tone="secondary" onPress={restart}>{t("order.restart")}</Button>
156
+ : <Button loading={requesting} onPress={request}>{failure ? t("order.retry") : t("order.request")}</Button>}
157
+ </Stack>
158
+ <Text variant="label" tone="muted">{t("order.log")}</Text>
159
+ <Timeline items={log} composeAccessibleName={({ position, total, label }) => t("order.logName", { position, total, label })} />
160
+ </Stack>
161
+ </Card>
162
+ </Container>
163
+ </ScrollView>;
164
+ ```
165
+
166
+ 단계 이름·문구, 실패 스위치, 900ms 지연은 예시다. 단계 정의와 확정·거절 판단은 서버와 제품 소유다.
167
+ 상태 전이는 스토리의 `orderReducer`·`orderStepsDescriptor`를 참고한다.
168
+
169
+ ## 플랫폼 차이
170
+
171
+ | 항목 | Web | Native |
172
+ | --- | --- | --- |
173
+ | 진행 문구 자리 | 빈 `role="status"`를 항상 마운트(알림 자리 유지) | 문구가 있을 때만 마운트(iOS는 live region을 무시하고 빈 Text가 틈을 남김, 2026-10-02 시뮬레이터 확인) |
174
+ | 이벤트 | `onClick` | `onPress` |
175
+ | 바깥 틀 | 제품 화면 레이아웃(문서 스크롤) | `ScrollView` > `Container` |
176
+ | 실패 Notice 발표 | `danger`는 늘 `role="alert"` | `announcement="assertive"`를 지정해야 발표(기본 `none`) |
177
+
178
+ ## 함정
179
+
180
+ - 실패할 때 커서를 옮기거나 완료 표시를 먼저 그리지 않는다. 서버가 거절한 단계를 사용자가 끝난 것으로 오해한다.
181
+ - `Timeline` 이름은 단계 이름과 다르게 짓는다("기록 N개 중 M번째"). "5단계 중"으로 읽히면 단계 수와 섞인다.
182
+ - Steps는 세로 방향·클릭 이동을 지원하지 않는다(`src/steps.ts`). 단계 사이를 눌러 이동하는 UI를 기대하지 않는다.
183
+ - 현재 스토리에는 네트워크·서버 오류로 요청 자체가 실패하는 경로가 없다(실패는 스위치로 만든 거절뿐이다).
184
+ - Web은 빈 `role="status"` Text도 Stack 자식이라 gap 한 칸(`spacing.lg` 20)이 Steps 아래에 남는다.
@@ -0,0 +1,215 @@
1
+ # 인증번호 확인과 다시 입력
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: [OtpField](../../otp-field.md), `showcase/web/src/patterns/stea-composition-previews.tsx`(`OtpVerifyRecover`), `showcase/native/src/stea-composition-previews.tsx`(`OtpVerifyRecover`, `Frame`), `showcase/shared/stea-compositions.ts`(`otpReducer`·`otpErrorMessage`), `src/otp-field.ts`, `src/result.ts`, `src/card.ts`, `src/container.ts`
9
+ - 스토리북: `배포/구성/입력과 작성/인증번호 확인과 다시 입력`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 문자·메일로 받은 숫자 인증번호를 입력하고 서버 확인을 기다린 뒤, 틀리면 남은 횟수를 보여 주고 다시 받게 하는 흐름에 쓴다.
14
+ 성공 화면은 서버가 번호를 확인한 뒤에만 나타나고, 입력 중에는 슬롯 위치가 움직이지 않는다.
15
+ 비밀번호·한 줄 텍스트 입력은 [PasswordField](../components/password-field.md)·[Field](../components/field.md)를 쓴다.
16
+
17
+ ## 구성 요소
18
+
19
+ | 컴포넌트 | 역할 | 지침 |
20
+ | --- | --- | --- |
21
+ | `Card` | 제목("인증번호 확인")·설명을 가진 틀 | [Card](../components/card.md) |
22
+ | `ContentTransition` | 입력 폼 ↔ 성공 결과 전환(기본 `fade`). 입력 중엔 같은 subtree를 유지 | [ContentTransition](../components/content-transition.md) |
23
+ | `OtpField` | `length` 6 숫자 칸. 확인 중 `busy`, 잠김 `disabled`, 틀림·요청 실패 `error` | [OtpField](../components/otp-field.md) |
24
+ | 재전송 안내 문구 | 다시 받은 뒤 "새 인증번호를 보냈어요…", 재전송 실패 문구(muted, live) | [Text](../components/text.md) |
25
+ | `Button` primary | 확인. 확인 중 `loading` | [Button](../components/button.md) |
26
+ | `Button` ghost | 인증번호 다시 받기. 대기 시간 동안 `disabled`, 라벨이 남은 초를 보여 줌. 재전송 요청 중 `loading` | [Button](../components/button.md) |
27
+ | `Result` `status="success"` | 성공 결과 + 행동 하나. Web은 `icon`을 비우면 CSS가 ✓를 그린다. Native는 기본 glyph가 없으니 `renderIcon`으로 제품 glyph를 넘긴다(비우면 빈 원) | [Result](../components/result.md) |
28
+ | `ScrollView` + `Container`(Native) | 바깥 틀. 세로 스크롤·위아래 여백, 좌우 gutter | [Container](../components/container.md), [화면 여백과 너비](../tokens/layout.md) |
29
+
30
+ ## 배치
31
+
32
+ ```text
33
+ Native 화면(ScrollView keyboardShouldPersistTaps="handled", 위아래 spacing.lg 20)
34
+ ┌ Container gutter 16(폭 < 600) · 20(폭 ≥ 600) ────┐
35
+ │ ┌ Card ──────────────────────────────────┐ │ body padding spacing.md 16
36
+ │ │ 인증번호 확인 (title) │ │
37
+ │ │ 문자로 받은 6자리 숫자를… (muted) │ │ 제목–설명 spacing.xs 8
38
+ │ │ ┌ form ──────────────────────────────┐ │ │
39
+ │ │ │ 인증번호 │ │ │
40
+ │ │ │ [2][4][6][8][1][ ] ← OtpField │ │ │ 칸 44, 칸 사이 spacing.xs 8
41
+ │ │ │ 도움말 / 오류(남은 횟수) │ │ │
42
+ │ │ │ ↕ spacing.md 16 │ │ │
43
+ │ │ │ 새 인증번호를 보냈어요 (재전송 후) │ │ │
44
+ │ │ │ ↕ spacing.md 16 │ │ │
45
+ │ │ │ [ 확인 ] │ │ │ ← 주 행동(primary), 위
46
+ │ │ │ ↕ spacing.md 16 │ │ │
47
+ │ │ │ [ 8초 후 다시 받을 수 있어요 ] │ │ │ ← 보조(ghost), 아래, 대기 중 disabled
48
+ │ │ └────────────────────────────────────┘ │ │
49
+ │ └────────────────────────────────────────┘ │
50
+ └──────────────────────────────────────────────────┘
51
+ 안전 영역·키보드: 화면 골격이 맡는다. 고정 영역 없음(행동은 본문 흐름 안)
52
+
53
+ 성공 후 같은 Card 안
54
+ ┌ Card ──────────────────────────────────┐
55
+ │ (✓) │ Result 위아래 spacing.xxxl 40, 좌우 spacing.xl 24
56
+ │ 인증을 마쳤어요 │
57
+ │ 서버가 번호를 확인한 뒤에만… │ ← description
58
+ │ [ 계속 ] │ ← Result 행동 하나(제품의 다음 단계)
59
+ └────────────────────────────────────────┘
60
+ ```
61
+
62
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
63
+ | --- | --- | --- | --- |
64
+ | 바깥 틀 | Web: 제품 화면 레이아웃(문서 스크롤). Native: `ScrollView` > `Container` | 이 구성은 바깥 폭·여백을 정하지 않는다. 스토리는 Card만 그린다. Web 단독 화면이면 [Container](../components/container.md) 같은 읽기 폭 컨테이너는 제품이 고른다. Native는 Card를 `ScrollView`(`keyboardShouldPersistTaps="handled"`) 안 `Container`에 둔다. 입력이 한 칸이라 [KeyboardFormScrollView](../components/keyboard-form-scroll-view.md)(실험, `react-native-keyboard-controller` 설치와 앱 루트 `KeyboardMotionProvider` 필요)는 기본값이 아니며, 여러 필드 폼 안에 이 구성을 넣을 때만 쓴다. 안전 영역은 화면 골격(내비게이션 헤더·탭 바)이 맡는다 | Native ScrollView 위아래 `spacing.lg` 20, Container gutter 폭 600 미만 `compact` 16 · 이상 `regular` 20(`layout.pagePadding`) |
65
+ | 틀 | `Card` | 바깥 틀 안, 스크롤과 함께 | body padding `spacing.md` 16, 제목–설명 `spacing.xs` 8 |
66
+ | 입력 | `OtpField` medium | Card 머리 아래 맨 위 | 칸 `control.minTouchTarget` 44, 칸 사이 `spacing.xs` 8 (`large`: 52 / `spacing.sm` 12) |
67
+ | 재전송 안내 | Text muted | 입력 아래, 재전송 후·재전송 실패 때만 | `Stack gap="md"` 16 |
68
+ | 행동 | Button primary → ghost | 안내 아래, 세로로 꽉 찬 폭(`Stack` 기본 `align="stretch"`가 채우므로 Web `layoutStyle`·Native `fullWidth`를 따로 주지 않는다) | 높이 `control.buttonHeight.medium` 44, 사이 `spacing.md` 16 |
69
+ | 성공 | `Result` | 폼 자리를 대신함 | 위아래 `spacing.xxxl` 40, 좌우 `spacing.xl` 24, gap `spacing.sm` 12 |
70
+
71
+ - 확인이 위, 다시 받기가 아래다. 세로로 쌓은 행동은 주 행동이 위다([Button](../components/button.md) 좁은 폭 규칙과 같다). 이 구성의 primary는 확인 하나다.
72
+
73
+ ## 흐름과 상태
74
+
75
+ 1. 번호를 입력한다. 6자리가 차면 Web은 Enter·확인 버튼으로 제출하고, Native는 `onComplete`로 바로 제출한다.
76
+ 2. 확인 중: 입력은 포커스를 유지한 채 읽기 전용(`busy`), 확인 버튼은 `loading`, 다시 받기는 `disabled`.
77
+ 3. 틀리면 OtpField `error`에 "인증번호가 맞지 않아요. N번 더 시도할 수 있어요."가 나온다. 값을 고치기 시작하면 오류가 내려간다.
78
+ 4. 3번 틀리면 잠긴다(`disabled`). 대기 시간이 끝난 뒤 "인증번호 다시 받기"로만 풀린다.
79
+ 5. 다시 받으면 상태가 초기화되고(시도 횟수·대기 시간 재시작) 재전송 안내 문구가 나온다.
80
+ 6. 서버가 맞다고 응답하면 `ContentTransition`이 폼을 `Result` success로 바꾼다.
81
+
82
+ | 상태 | 모습 | 포커스·알림 |
83
+ | --- | --- | --- |
84
+ | 기본 | 빈 칸·채운 칸(채운 칸 테두리 `content.brand`), 확인은 6자리 전 `disabled`, 다시 받기는 대기 라벨 | 첫 칸 |
85
+ | 진행 중 | 확인 요청: 입력 읽기 전용(`busy`), 확인 `loading`, 다시 받기 `disabled` | 입력에 포커스 유지 |
86
+ | 실패 | 확인 요청 실패(네트워크·서버): 시도 횟수를 줄이지 않고 OtpField `error`에 재시도 문구, 값 유지. 확인은 다시 누를 수 있다 | 오류는 필드 설명으로 읽힌다 |
87
+ | 틀림 | OtpField `error`에 남은 횟수 | 오류는 필드 설명으로 읽힌다 |
88
+ | 잠김 | OtpField `disabled` + `error`에 "시도 횟수를 모두 썼어요. 인증번호를 다시 받아 주세요." | 다시 받기만 동작 |
89
+ | 재전송 대기 | ghost 라벨 "N초 후 다시 받을 수 있어요", `disabled` | 1초마다 라벨 갱신 |
90
+ | 재전송 요청 중 | 다시 받기 `loading`. 두 번 보내지 않는다 | — |
91
+ | 재전송 실패 | 대기 시간을 시작하지 않고 오류 문구(입력 아래 Text), 다시 받기는 바로 다시 누를 수 있다 | 실패 문구는 Web `role="status"`, Native live region |
92
+ | 재전송 완료 | 안내 문구(muted) | Web: 입력으로 포커스 이동. 문구는 Web `role="status"`, Native live region |
93
+ | 성공 | `Result` success, 행동 하나(제품의 다음 단계, 예 `otp.continue`). 스토리의 "다른 번호로 다시 해 보기"는 데모 초기화용 | Web: Result(`tabIndex={-1}`)로 포커스 이동 |
94
+
95
+ - 스토리의 정답 `246810`, 대기 10초(실서비스는 보통 30~60초), 지연 900ms는 데모 값이다. 시도 횟수·대기 시간·검증은 서버와 제품 소유다.
96
+ - 문구 키는 상태별 상수로 둔다(아래 `otpErrorKey`). 상태 이름으로 키를 조립하면 상태 표와 키가 어긋난다.
97
+
98
+ | 상태 | 문구 키 | 변수 |
99
+ | --- | --- | --- |
100
+ | 틀림 | `otp.wrong` | `left` |
101
+ | 잠김 | `otp.locked` | — |
102
+ | 확인 요청 실패 | `otp.requestFailed` | — |
103
+ | 재전송 실패 | `otp.resendFailed` | — |
104
+ | 재전송 대기 | `otp.resendWait` | `seconds` |
105
+ | 재전송 완료 | `otp.resent` | — |
106
+
107
+ ## 코드 골격
108
+
109
+ ```tsx
110
+ // Web
111
+ import { useEffect, useRef } from "react";
112
+ import { Button } from "@hjmds/react/actions";
113
+ import { ContentTransition } from "@hjmds/react/content-transition";
114
+ import { Card } from "@hjmds/react/display";
115
+ import { Result } from "@hjmds/react/feedback";
116
+ import { OtpField } from "@hjmds/react/forms";
117
+ import { Stack, Text } from "@hjmds/react/layout";
118
+
119
+ // 상태 → 문구 키. 키를 상태 이름으로 조립하지 않는다.
120
+ const otpErrorKey = { wrong: "otp.wrong", locked: "otp.locked", requestFailed: "otp.requestFailed" } as const;
121
+ const error = errorKind ? t(otpErrorKey[errorKind], { left: attemptsLeft }) : undefined;
122
+
123
+ const inputRef = useRef<HTMLInputElement>(null);
124
+ const resultRef = useRef<HTMLDivElement>(null);
125
+ // 재전송 횟수를 의존성으로 둔다. 불리언 resent는 두 번째 재전송부터 값이 같아 실행되지 않는다.
126
+ useEffect(() => { if (resendCount > 0) inputRef.current?.focus(); }, [resendCount]);
127
+ useEffect(() => { if (verified) resultRef.current?.focus(); }, [verified]);
128
+
129
+ <Card title={t("otp.title")} description={t("otp.description")}>
130
+ <ContentTransition stateKey={verified ? "verified" : "form"}>
131
+ {verified
132
+ ? <Result ref={resultRef} tabIndex={-1} status="success" title={t("otp.success")}
133
+ description={t("otp.successBody")} actions={[{ label: t("otp.continue"), onAction: next }]} />
134
+ : <form onSubmit={(event) => { event.preventDefault(); submit(); }}>
135
+ <Stack gap="md">
136
+ <OtpField ref={inputRef} label={t("otp.field")} length={6} value={code}
137
+ onValueChange={change} busy={verifying} disabled={locked}
138
+ description={t("otp.hint")} {...(error ? { error } : {})} />
139
+ {resendFailed ? <Text role="status" tone="muted">{t("otp.resendFailed")}</Text>
140
+ : resendCount > 0 ? <Text role="status" tone="muted">{t("otp.resent")}</Text> : null}
141
+ <Button type="submit" loading={verifying} disabled={!canSubmit && !verifying}>{t("otp.submit")}</Button>
142
+ <Button type="button" tone="ghost" loading={resending} disabled={resendIn > 0 || verifying} onClick={resend}>
143
+ {resendIn > 0 ? t("otp.resendWait", { seconds: resendIn }) : t("otp.resend")}
144
+ </Button>
145
+ </Stack>
146
+ </form>}
147
+ </ContentTransition>
148
+ </Card>;
149
+ ```
150
+
151
+ ```tsx
152
+ // Native
153
+ import { ScrollView, useWindowDimensions } from "react-native";
154
+ import { spacing } from "@hjmds/design-contracts/foundations";
155
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
156
+ import { Button } from "@hjmds/react-native/actions";
157
+ import { ContentTransition } from "@hjmds/react-native/content-transition";
158
+ import { Card } from "@hjmds/react-native/data-display";
159
+ import { Result } from "@hjmds/react-native/feedback";
160
+ import { OtpField } from "@hjmds/react-native/inputs";
161
+ import { Container, Stack, Text } from "@hjmds/react-native/primitives";
162
+
163
+ const otpErrorKey = { wrong: "otp.wrong", locked: "otp.locked", requestFailed: "otp.requestFailed" } as const;
164
+ const error = errorKind ? t(otpErrorKey[errorKind], { left: attemptsLeft }) : undefined;
165
+ const { width } = useWindowDimensions();
166
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
167
+
168
+ <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingVertical: spacing.lg }}>
169
+ <Container size="reading" gutter={gutter}>
170
+ <Card title={t("otp.title")} description={t("otp.description")}>
171
+ <ContentTransition stateKey={verified ? "verified" : "form"}>
172
+ {verified
173
+ ? <Result status="success" title={t("otp.success")} description={t("otp.successBody")}
174
+ actions={[{ label: t("otp.continue"), onAction: next }]}
175
+ renderIcon={({ color }) => <CheckGlyph color={color} size={28} />} />
176
+ : <Stack gap="md">
177
+ <OtpField label={t("otp.field")} length={6} value={code} onValueChange={change}
178
+ onComplete={submit} busy={verifying} disabled={locked}
179
+ description={t("otp.hint")} {...(error ? { error } : {})} />
180
+ {resendFailed ? <Text accessibilityLiveRegion="polite" tone="muted">{t("otp.resendFailed")}</Text>
181
+ : resendCount > 0 ? <Text accessibilityLiveRegion="polite" tone="muted">{t("otp.resent")}</Text> : null}
182
+ <Button loading={verifying} disabled={!canSubmit && !verifying} onPress={submit}>{t("otp.submit")}</Button>
183
+ <Button tone="ghost" loading={resending} disabled={resendIn > 0 || verifying} onPress={resend}>
184
+ {resendIn > 0 ? t("otp.resendWait", { seconds: resendIn }) : t("otp.resend")}
185
+ </Button>
186
+ </Stack>}
187
+ </ContentTransition>
188
+ </Card>
189
+ </Container>
190
+ </ScrollView>;
191
+ ```
192
+
193
+ 상태 전이는 스토리의 `otpReducer`(편집 → 확인 중 → 틀림/잠김/성공, 재전송은 대기 후 초기화)를 참고해 제품이 소유한다.
194
+ 확인 요청 실패·재전송 요청 중·실패는 스토리에 없으므로 제품이 상태를 더한다(시도 횟수를 줄이지 않는다).
195
+
196
+ ## 플랫폼 차이
197
+
198
+ | 항목 | Web | Native |
199
+ | --- | --- | --- |
200
+ | 제출 | `<form onSubmit>` + `type="submit"` 버튼(Enter 제출) | `onComplete`로 6자리 도달 시 자동 제출 + 확인 버튼 |
201
+ | 바깥 틀 | 제품 화면 레이아웃(문서 스크롤) | `ScrollView keyboardShouldPersistTaps="handled"` > `Container` |
202
+ | 재전송 후 포커스 | 입력으로 `focus()` | 이동하지 않음(스토리) |
203
+ | 성공 후 포커스 | `Result`(`tabIndex={-1}`)로 이동 | 이동하지 않음(스토리) |
204
+ | 성공 아이콘 | `icon`을 비우면 CSS가 ✓ | `renderIcon`으로 제품 glyph(비우면 빈 원) |
205
+ | 상태 문구 | `role="status"` | `accessibilityLiveRegion="polite"` |
206
+ | busy 표현 | read-only + `aria-busy` | `editable={false}` + `accessibilityState.busy` |
207
+ | 자동 채움 | `autoComplete="one-time-code"` | `textContentType="oneTimeCode"` |
208
+
209
+ ## 함정
210
+
211
+ - 대기 타이머 effect 의존성에 `phase`를 넣으면 확인 요청마다 1초 타이머가 다시 시작돼 대기 시간이 줄지 않는다(2026-10-02 실측). 남은 초와 성공 여부만 의존성으로 둔다.
212
+ - 확인 중·잠김·성공 상태에서는 입력 값을 바꾸지 않는다. 응답과 다른 번호가 화면에 남는다.
213
+ - 성공 화면 전환은 서버 응답 뒤에만 한다. 6자리가 찼다는 이유로 미리 바꾸지 않는다.
214
+ - 네트워크·서버 실패를 "틀림"으로 처리해 시도 횟수를 줄이지 않는다. 사용자가 맞는 번호를 넣고도 잠긴다.
215
+ - 현재 스토리에는 확인 요청 실패·재전송 요청 중·재전송 실패 경로가 없다. 성공 행동 "다른 번호로 다시 해 보기"는 데모 초기화용이다.
@@ -0,0 +1,140 @@
1
+ # 캐릭터와 시작 행동
2
+
3
+ - 단계: 구성
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: `src/component-recipes.ts` `emptyStateRecipe`, `packages/react/src/styles.css` `.hjm-empty-state`, `showcase/shared/stea-expressions.ts`, `showcase/{web/src/patterns,native/src}/stea-expression-previews.tsx`(`PixelEmptyState`, Native `Frame`), `src/container.ts`
9
+ - 스토리북: `배포/구성/피드백과 복구/캐릭터와 시작 행동`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 아직 만든 것이 없는 첫 빈 화면에 제품 캐릭터를 움직여 보이고 첫 행동 하나로 이끌 때 쓴다. 검색 결과 0건이나 오류처럼
14
+ 캐릭터가 어울리지 않는 빈 상태에는 쓰지 않고 기본 [EmptyState](../components/empty-state.md)만 쓴다.
15
+
16
+ ## 구성 요소
17
+
18
+ | 컴포넌트 | 역할 | 지침 |
19
+ | --- | --- | --- |
20
+ | EmptyState | 그림·제목·설명·행동 한 묶음 | [EmptyState](../components/empty-state.md) |
21
+ | 제품 캐릭터(스토리: 12×12 픽셀 스프라이트) | 장식 그림, 4프레임 220ms 반복 | 제품 소유 |
22
+ | Button `primary` | 첫 기록 남기기(시작 행동) | [Button](../components/button.md) |
23
+ | Button `ghost` | 움직임 멈추기/다시 움직이기 | [Button](../components/button.md) |
24
+ | Text `tone="muted"` 상태 문구 | 시작 행동 결과 | [Text](../components/text.md) |
25
+ | `ScrollView` + `Container`(Native) | 바깥 틀. 세로 스크롤·위아래 여백, 좌우 gutter | [Container](../components/container.md), [화면 여백과 너비](../tokens/layout.md) |
26
+
27
+ ## 배치
28
+
29
+ ```text
30
+ Native 화면(ScrollView, 위아래 spacing.lg 20) > Container gutter 16(폭 < 600) · 20(폭 ≥ 600)
31
+ ┌──────── Stack gap spacing.md 16 ─────────┐
32
+ │ ┌──── EmptyState (가운데 정렬) ────────┐ │
33
+ │ │ ┌────────┐ │ │
34
+ │ │ │ 캐릭터 │ 96×96 │ │
35
+ │ │ └────────┘ ↕ spacing.xs 8│ │
36
+ │ │ 아직 남긴 기록이 없어요 (제목) │ │
37
+ │ │ 말랑이가 첫 기록을 기다리고 있어요. │ │
38
+ │ │ [ 첫 기록 남기기 ] primary │ │ ← 주 행동
39
+ │ └────────────────────────────────────────┘ │
40
+ │ [ 움직임 멈추기 ] ghost │ ← 보조(멈춤 제어)
41
+ │ 상태 문구 (muted, live) │
42
+ └───────────────────────────────────────────┘
43
+ 고정 영역 없음. 안전 영역은 화면 골격이 맡는다
44
+ ```
45
+
46
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
47
+ | --- | --- | --- | --- |
48
+ | 바깥 틀 | Web: 제품 화면 레이아웃(문서 스크롤). Native: `ScrollView` > `Container` | 이 구성은 바깥 폭·여백을 정하지 않는다(Web 스토리는 Stack만 그린다). Native는 `ScrollView` 안 [Container](../components/container.md)에 둔다. 입력이 없어 키보드 처리는 없다. 안전 영역은 화면 골격(내비게이션 헤더·탭 바)이 맡는다 | Native ScrollView 위아래 `spacing.lg` 20, Container gutter 폭 600 미만 `compact` 16 · 이상 `regular` 20(`layout.pagePadding`) |
49
+ | 빈 상태 | EmptyState `density="regular"` | 본문 가운데 | 위아래 Web `spacing.xxl` 32 / Native `spacing.xxxl` 40, 좌우 `spacing.xl` 24, 슬롯 사이 `spacing.xs` 8 |
50
+ | 캐릭터 | Web `icon` / Native `illustration` | EmptyState 맨 위 | 96×96(12칸 × `spacing.xs` 8). Native는 `illustrationStyle`로 칸을 96에 맞춘다 |
51
+ | 시작 행동 | Button `primary` | EmptyState `action` | 높이 44, 하나만 |
52
+ | 멈춤 제어 | Button `ghost` | EmptyState 아래, 꽉 찬 폭(`Stack` 기본 `align="stretch"`) | 묶음 사이 `spacing.md` 16 |
53
+ | 상태 문구 | Text muted | 맨 아래. Web은 빈 `role="status"`를 늘 마운트, Native는 문구가 있을 때만 | `spacing.md` 16 |
54
+
55
+ ## 흐름과 상태
56
+
57
+ 1. 화면이 열리면 캐릭터가 220ms 간격 4프레임으로 반복한다.
58
+ 2. "첫 기록 남기기"를 누르면 제품의 작성 화면으로 간다(스토리는 상태 문구만 보인다).
59
+ 3. "움직임 멈추기"를 누르면 첫 프레임에서 멈추고 라벨이 "다시 움직이기"로 바뀐다.
60
+
61
+ | 상태 | 모습 | 포커스·알림 |
62
+ | --- | --- | --- |
63
+ | 기본 | 캐릭터가 움직인다 | 캐릭터는 숨김, 제목·설명이 의미를 전한다 |
64
+ | 진행 중 | 시작 행동이 화면 이동이면 없다. 서버 요청(예: 첫 항목 생성)이면 primary `loading` | 포커스는 버튼 유지 |
65
+ | 실패 | 시작 행동이 서버 요청일 때 실패하면 상태 문구 자리에 실패 문구, primary는 다시 누를 수 있다. 빈 상태 자체를 불러오지 못한 경우는 이 구성이 아니라 오류 화면([Result](../components/result.md) failure)이다 | 상태 문구가 Web `role="status"`, Native live region으로 읽힌다 |
66
+ | 멈춤 | 첫 프레임 정지 | 멈춤 버튼 라벨이 바뀐다 |
67
+ | reduced motion | 타이머 없이 첫 프레임 | — |
68
+ | 탭 숨김(Web)·앱 배경(Native) | 타이머를 걸지 않는다 | — |
69
+ | 시작 행동 후 | 상태 문구 표시 | Web `role="status"`, Native `accessibilityLiveRegion` |
70
+ | 다크 | 캐릭터 색을 테마에서 고른다(Native 예: 외곽 `text`, 몸 `contentBrand`, 밝은 칸 `bg`) | — |
71
+
72
+ ## 코드 골격
73
+
74
+ ```tsx
75
+ // Web
76
+ import { EmptyState } from "@hjmds/react/feedback";
77
+ import { Button } from "@hjmds/react/actions";
78
+ import { Stack, Text } from "@hjmds/react/layout";
79
+
80
+ // 상태 → 문구 키
81
+ const statusKey = { idle: undefined, started: "records.empty.started", failed: "records.empty.startFailed" } as const;
82
+ const statusText = statusKey[startState];
83
+
84
+ <Stack gap="md">
85
+ <EmptyState icon={<ProductMascot paused={paused} />} title={t("records.empty.title")}
86
+ description={t("records.empty.description")}
87
+ action={<Button loading={starting} onClick={startRecord}>{t("records.empty.start")}</Button>} />
88
+ <Button tone="ghost" onClick={() => setPaused((v) => !v)}>
89
+ {paused ? t("mascot.resume") : t("mascot.pause")}
90
+ </Button>
91
+ <Text role="status" tone="muted">{statusText ? t(statusText) : ""}</Text>
92
+ </Stack>;
93
+ ```
94
+
95
+ ```tsx
96
+ // Native
97
+ import { ScrollView, useWindowDimensions } from "react-native";
98
+ import { spacing } from "@hjmds/design-contracts/foundations";
99
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
100
+ import { EmptyState } from "@hjmds/react-native/feedback";
101
+ import { Button } from "@hjmds/react-native/actions";
102
+ import { Container, Stack, Text } from "@hjmds/react-native/primitives";
103
+
104
+ const statusKey = { idle: undefined, started: "records.empty.started", failed: "records.empty.startFailed" } as const;
105
+ const statusText = statusKey[startState];
106
+ const { width } = useWindowDimensions();
107
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
108
+
109
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
110
+ <Container gutter={gutter}>
111
+ <Stack gap="md">
112
+ <EmptyState illustration={<ProductMascot paused={paused} />} illustrationStyle={{ width: 96, height: 96 }}
113
+ title={t("records.empty.title")} description={t("records.empty.description")}
114
+ action={<Button loading={starting} onPress={startRecord}>{t("records.empty.start")}</Button>} />
115
+ <Button tone="ghost" onPress={() => setPaused((v) => !v)}>
116
+ {paused ? t("mascot.resume") : t("mascot.pause")}
117
+ </Button>
118
+ {statusText ? <Text accessibilityLiveRegion="polite" tone="muted">{t(statusText)}</Text> : null}
119
+ </Stack>
120
+ </Container>
121
+ </ScrollView>;
122
+ ```
123
+
124
+ 캐릭터 그림·프레임·색 배정, 문구는 제품 소유다. 스토리의 픽셀 박쥐와 "말랑이"는 예시다.
125
+
126
+ ## 플랫폼 차이
127
+
128
+ | 항목 | Web | Native |
129
+ | --- | --- | --- |
130
+ | 그림 슬롯 | `icon` | `illustration` + `illustrationStyle`(기본 칸이 아이콘 크기 `glyph.lg`라 캐릭터가 넘친다) |
131
+ | 세로 여백 | `.hjm-empty-state` `spacing.xxl` 32 | recipe `spacing.xxxl` 40 |
132
+ | 일시정지 감지 | `document.hidden` | `AppState` |
133
+ | 상태 문구 자리 | 빈 `role="status"`를 늘 마운트 | 문구가 있을 때만 마운트 |
134
+ | 바깥 틀 | 제품 화면 레이아웃(문서 스크롤) | `ScrollView` > `Container` |
135
+
136
+ ## 함정
137
+
138
+ - 5초 넘게 반복되는 움직임에는 멈춤 제어를 둔다(WCAG 2.2.2). 스토리의 ghost 버튼이 그 자리다.
139
+ - 캐릭터에 접근성 이름을 붙이지 않는다. EmptyState가 그림을 장식으로 숨긴다.
140
+ - 현재 스토리의 시작 행동은 상태 문구만 바꾼다. 진행 중·실패 경로는 없다.