@hjmds/design-contracts 1.12.1 → 1.13.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 (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 +26 -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 +31 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +20 -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 +378 -0
  67. package/docs/sheet.md +21 -0
  68. package/docs/splitter.md +8 -2
  69. package/docs/theming.md +36 -29
  70. package/docs/toggle-group.md +13 -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 +107 -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 +221 -0
  169. package/docs/usage/components/section.md +111 -0
  170. package/docs/usage/components/segmented-control.md +146 -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 +157 -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 +279 -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,245 @@
1
+ # 대시보드
2
+
3
+ - 단계: 화면
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: `showcase/web/src/patterns/Dashboard.stories.tsx`, `showcase/native/src/Dashboard.stories.tsx`, `showcase/shared/dashboard-pattern.ts`, `showcase/native/src/pattern-status.tsx`, `packages/react/src/styles.css`(`.hjm-statistic`, `.hjm-text`)
9
+ - 스토리북: `배포/화면/콘텐츠/대시보드`
10
+
11
+ ## 목적
12
+
13
+ 한 기간의 개인 활동을 숫자 요약 → 날짜별 활동 → 기록 목록 순서로 돌아보는 화면이다.
14
+ 기간을 바꾸면 세 영역이 같은 데이터에서 함께 다시 계산된다. 차트·비교·목표 설정은 없다.
15
+ 수치·기간·기록은 제품 데이터이며 스토리의 "예제 기록입니다" 안내는 showcase 전용이다.
16
+
17
+ ## 영역 구조
18
+
19
+ ```text
20
+ 좁은 폭(Native·모바일 Web) — 전체가 하나의 세로 스크롤, 고정 영역 없음
21
+ ┌ 상단 안전 영역 (화면 소유 아님: 헤더·TopBar safeAreaTop) ┐
22
+ │ ScrollView 위아래 spacing.lg 20 │
23
+ │ Container gutter: 폭<600 compact 16 · 이상 regular 20 │
24
+ │ ① 머리 │
25
+ │ 제목 Heading level3 · semanticLevel 1 │
26
+ │ 소개 Text (Web as="p") │ 직계 요소 사이 spacing.xl 24
27
+ │ [최근 7일] [9월] [8월] ← 기간 선택 │ ghost + selected, wrap gap sm 12
28
+ │ ② "2026-09-01 — 2026-09-30" (상태 알림) │
29
+ │ (오류면 여기 Notice danger + [다시 시도]) │
30
+ │ ┌ ③ 요약 Surface padding lg 20 ─────────────────────────┐ │
31
+ │ │ 남긴 기록 ← 라벨(muted) │ │ 라벨-값 spacing.xxs 4
32
+ │ │ 7 ← 값(tabular-nums) │ │
33
+ │ │ ↕ spacing.lg 20 │ │
34
+ │ │ 기록한 날 / 6 │ │
35
+ │ │ ↕ 20 │ │
36
+ │ │ 머문 시간 (분) / 125 │ │
37
+ │ └────────────────────────────────────────────────────────┘ │
38
+ │ ④ 활동 │
39
+ │ "기록한 날" Text emphasis=strong │
40
+ │ [날짜 목록으로 보기] ← 보기 전환(ghost) │
41
+ │ ActivityHeatmap (grid ↔ list) │
42
+ │ ⑤ 기록 List — 최신 날짜가 위 │
43
+ │ ├ ListRow 제목 / "날짜 · N분" │
44
+ │ └ … │
45
+ │ (기록 0개면 ④⑤ 대신 EmptyState + [9월 보기]) │
46
+ └ 아래: 고정 영역 없음, 내용이 스크롤된다 ───────────────────────┘
47
+ ```
48
+
49
+ 넓은 폭 Web도 같은 한 열이다. [Container](../components/container.md) `size="content"`(`layout.contentMaxWidth` 1200)가
50
+ 폭을 묶는다. 요약 수치를 가로로 놓는 배치는 스토리에서 확인되지 않았다.
51
+
52
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
53
+ | --- | --- | --- | --- |
54
+ | 바깥 틀 | Web `main` > [Container](../components/container.md) `size="content"` `gutter` > Stack `gap="xl"`, 문서 스크롤 · Native `ScrollView` > Container `size="content"` `gutter` > Stack `gap="xl"` | 화면 전체, 세로 스크롤 하나 | 좌우 Container gutter: 폭 < 600 `compact` 16, 이상 `regular` 20([화면 여백](../tokens/layout.md)). Native ScrollView 위아래 `spacing.lg` 20(`paddingVertical`). 직계 요소 사이 `spacing.xl` 24. 위 안전 영역은 내비게이션 헤더·[TopBar](../components/top-bar.md) `safeAreaTop`이 맡고, 헤더 없이 띄울 때만 제품이 감싼다. 아래 고정 영역이 없어 inset은 스크롤로 지나간다. 입력이 없어 키보드 처리는 없다 |
55
+ | ① 머리 | [Heading](../components/heading.md) `level="level3"` `semanticLevel={1}` · Text(소개) · Stack `axis="inline" wrap gap="sm"` > Button `ghost` | 맨 위 | 제목 24/32 heavy. 버튼 높이 44(`control.buttonHeight.medium`), 사이 `spacing.sm` 12 |
56
+ | ② 기간 표시 | Text `role="status"`(Web) · `accessibilityLiveRegion="polite"` Text(Native) | ① 아래 | 위아래 24 |
57
+ | ③ 요약 | Surface `padding="lg"` > Stack `gap="lg"` > AnimatedStatistic ×3 | ② 아래 | 안쪽 `spacing.lg` 20, 수치 사이 20, 라벨-값 `spacing.xxs` 4 |
58
+ | ④ 활동 | Text `emphasis="strong"` · Button `ghost` · ActivityHeatmap `view` | ③ 아래, 세 요소가 바깥 Stack 직계 | 요소 사이 24 |
59
+ | ⑤ 기록 | List > ListRow(누름 없음) | ④ 아래 | 두 줄 행 최소 68(`layout.rowHeight.twoLine`). 기록은 최신 날짜가 위(내림차순)다. 요약·히트맵과 같은 기간 필터 결과를 뒤집어 쓴다(`dashboard-pattern.ts` `records.slice().reverse()`) |
60
+ | 빈 상태 | EmptyState + `action` Button | ④⑤ 자리 | Web 위아래 `spacing.xxl` 32 · Native 위아래 `spacing.xxxl` 40·좌우 `spacing.xl` 24 |
61
+
62
+ ④의 제목·토글·히트맵이 바깥 Stack 직계라 사이가 24로 벌어진다. 제목과 전환 버튼을 한 줄에 묶으려면
63
+ [Section](../components/section.md)의 `title`·`action`(머리 한 줄, 사이 `spacing.sm` 12, Web 폭 600(`breakpoint.medium`) 미만에서 세로로 쌓임)을 쓴다.
64
+
65
+ ## 버튼과 행동 위치
66
+
67
+ | 행동 | 컴포넌트·tone | 위치 | 개수·순서 |
68
+ | --- | --- | --- | --- |
69
+ | 기간 선택 | SegmentedControl `presentation="pills"` | ① 소개문 아래 한 줄, 시작 정렬, wrap | 3(짧은 기간 → 긴 기간 → 과거). 기본 선택은 현재 달(스토리 `month`). 화면에 primary가 없다 — 이 화면의 주 행동은 읽기다 |
70
+ | 보기 전환 | Button `ghost` | ④ 제목 아래 | 1. 라벨은 바뀔 보기를 말한다(`viewToggleKey`: grid → `dashboard.showList`, list → `dashboard.showGrid`) |
71
+ | 빈 상태 복구 | EmptyState `action` > Button(기본 primary) | EmptyState 맨 아래 가운데 | 1. 기록 있는 기간(스토리는 현재 달)으로 돌린다 |
72
+ | 다시 시도 | [Notice](../components/notice.md) `action` > Button `tone="secondary" size="small"` | ② 아래 Notice 끝(Web 같은 줄 끝, Native 문구 아래) | 1. 오류일 때만 |
73
+ | 기록 열기 | — | — | 스토리에서는 누를 수 없다. 상세가 있으면 ListRow에 `onClick`/`onPress`를 주고 [작품 탐색](discovery-gallery.md)처럼 Sheet로 연다 |
74
+ | 파괴 행동 | — | — | 없음 |
75
+
76
+ ## 상태
77
+
78
+ | 상태 | 화면 모습 | 행동 |
79
+ | --- | --- | --- |
80
+ | 기본 | ①~⑤ 모두. AnimatedStatistic이 이전 값에서 새 값으로 굴러간다(모션 줄이기면 즉시) | 기간 선택·보기 전환 |
81
+ | 로딩 | 스토리에 없음. ①② 그대로, ③ Surface 안에 수치 3개 모양의 [Skeleton](../components/skeleton.md)(라벨 `shape="text"` 40% + 값 `shape="block"` 30%, 사이 `spacing.xxs` 4), ④⑤는 그리지 않는다. 로딩 사실은 영역 단위로 한 번 알린다(Web 감싼 Surface `aria-busy`·`aria-label`, Native 첫 Skeleton `accessibilityLabel`) | 기간 선택은 그대로 둔다 |
82
+ | 빈 | ③ 수치는 0, ④⑤ 자리에 EmptyState(제목·설명) | [9월 보기] 같은 기록 있는 기간으로 |
83
+ | 오류 | 스토리에 없음. ② 바로 아래 [Notice](../components/notice.md) `tone="danger"`(Native `announcement="assertive"` — 기본 `none`). 문구는 원인별 키(`errorKey`: 네트워크 `dashboard.error.network`, 서버 `dashboard.error.server`). 이전 데이터가 있으면 ③④⑤를 유지하고, 첫 로딩 실패면 ③④⑤를 그리지 않는다. 다시 시도가 또 실패하면 같은 Notice를 같은 자리에 두고 버튼 `loading`만 푼다(Notice를 쌓지 않는다) | Notice `action` 다시 시도 1개(`tone="secondary" size="small"`, 요청 중 `loading`) |
84
+ | 기간 변경 | ② 기간 문구가 바뀌어 읽히고 ③④⑤가 같이 바뀐다 | — |
85
+ | 날짜 값 모름 | 히트맵 값 `null`은 `formatDay`로 "확인되지 않음"이라 읽힌다. 0과 구분해 넘긴다 | — |
86
+
87
+ ## 사용하는 지침
88
+
89
+ | 지침 | 쓰는 곳 |
90
+ | --- | --- |
91
+ | [Container](../components/container.md) | 바깥 틀 폭·좌우 여백 |
92
+ | [Stack](../components/stack.md) | 바깥 세로 리듬, 기간 버튼 줄, 요약 안 |
93
+ | [Heading](../components/heading.md) | ① 화면 제목 |
94
+ | [Text](../components/text.md) | 소개·기간 표시·활동 제목 |
95
+ | [SegmentedControl](../components/segmented-control.md) | 기간 단일 선택 |
96
+ | [Button](../components/button.md) | 보기 전환, 복구, 다시 시도 |
97
+ | [Surface](../components/surface.md) | ③ 요약 카드 |
98
+ | [Statistic](../components/statistic.md) | ③ `AnimatedStatistic` |
99
+ | [ActivityHeatmap](../components/activity-heatmap.md) | ④ 날짜별 활동 |
100
+ | [List](../components/list.md) · [ListRow](../components/list-row.md) | ⑤ 기록 |
101
+ | [EmptyState](../components/empty-state.md) | 기록 없음 |
102
+ | [Skeleton](../components/skeleton.md) | 로딩 |
103
+ | [Notice](../components/notice.md) | 오류 |
104
+ | [화면 여백과 너비](../tokens/layout.md) | gutter·최대 폭 |
105
+ | [간격](../tokens/spacing.md) | `spacing.xxs`·`sm`·`lg`·`xl` |
106
+
107
+ ## 코드 골격
108
+
109
+ 수치 라벨·기간 목록·`formatDay` 문구는 제품 소유다. 상태마다 다른 문구는 키를 문자열로 조립하지 않고
110
+ 상태→키 상수 표로 고른다. 그래야 상태 표와 키 목록이 한 곳에서 맞는다. 아래 두 예는 설치 버전 타입으로
111
+ 검사했다(`period`·`data`·`error` 등 상태 값은 제품이 둔다).
112
+
113
+ ```tsx
114
+ // Web
115
+ import { Container, Stack, Surface, Text } from "@hjmds/react/layout";
116
+ import { Heading } from "@hjmds/react/heading";
117
+ import { Button } from "@hjmds/react/actions";
118
+ import { SegmentedControl } from "@hjmds/react/selection";
119
+ import { EmptyState, Notice, Skeleton } from "@hjmds/react/feedback";
120
+ import { AnimatedStatistic } from "@hjmds/react/statistic-motion";
121
+ import { ActivityHeatmap } from "@hjmds/react/activity-heatmap";
122
+ import { List, ListRow } from "@hjmds/react/display";
123
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
124
+
125
+ type Period = "week" | "month" | "lastMonth";
126
+ type LoadError = "network" | "server";
127
+ const periods: readonly Period[] = ["week", "month", "lastMonth"];
128
+ const periodKey = { week: "dashboard.period.week", month: "dashboard.period.month", lastMonth: "dashboard.period.lastMonth" } as const satisfies Record<Period, string>;
129
+ const errorKey = { network: "dashboard.error.network", server: "dashboard.error.server" } as const satisfies Record<LoadError, string>;
130
+ // 라벨은 지금 보기가 아니라 바뀔 보기를 말한다.
131
+ const viewToggleKey = { grid: "dashboard.showList", list: "dashboard.showGrid" } as const;
132
+ const gutter = resolveWindowClass(window.innerWidth) === "compact" ? "compact" : "regular";
133
+
134
+ <main><Container size="content" gutter={gutter}><Stack gap="xl">
135
+ <Heading level="level3" semanticLevel={1}>{t("dashboard.title")}</Heading>
136
+ <Text as="p">{t("dashboard.intro")}</Text>
137
+ <SegmentedControl label={t("dashboard.period")} presentation="pills"
138
+ items={periods.map(id => ({ value: id, label: t(periodKey[id]) }))} value={period} onValueChange={setPeriod} />
139
+ <Text role="status">{t("dashboard.range", range)}</Text>
140
+ {error ? <Notice tone="danger" title={t(errorKey[error])}
141
+ action={<Button tone="secondary" size="small" loading={retrying} onClick={retry}>{t("common.retry")}</Button>} /> : null}
142
+ {loading && !data ? (
143
+ <Surface padding="lg" aria-busy="true" aria-label={t("dashboard.loading")}><Stack gap="lg">
144
+ {[0, 1, 2].map(i => <Stack key={i} gap="xxs"><Skeleton shape="text" width="40%" /><Skeleton shape="block" width="30%" /></Stack>)}
145
+ </Stack></Surface>
146
+ ) : data ? <>
147
+ <Surface padding="lg"><Stack gap="lg">
148
+ <AnimatedStatistic descriptor={{ id: "count", label: t("dashboard.count") }} value={data.count} locale={locale} />
149
+ <AnimatedStatistic descriptor={{ id: "days", label: t("dashboard.activeDays") }} value={data.activeDays} locale={locale} />
150
+ <AnimatedStatistic descriptor={{ id: "minutes", label: t("dashboard.minutes") }} value={data.minutes} locale={locale} />
151
+ </Stack></Surface>
152
+ {data.count ? <>
153
+ <Text emphasis="strong">{t("dashboard.activity")}</Text>
154
+ <Button tone="ghost" onClick={() => setList(v => !v)}>{t(viewToggleKey[list ? "list" : "grid"])}</Button>
155
+ <ActivityHeatmap descriptor={data.heatmap} label={t("dashboard.activity")} formatDay={formatDay} view={list ? "list" : "grid"} />
156
+ <List label={t("dashboard.records")}>{data.records.map(r =>
157
+ <ListRow key={r.id} title={r.title} description={t("dashboard.recordMeta", { date: r.date, minutes: r.minutes })} />)}</List>
158
+ </> : <EmptyState title={t("dashboard.empty.title")} description={t("dashboard.empty.body")}
159
+ action={<Button onClick={() => setPeriod("month")}>{t("dashboard.reset")}</Button>} />}
160
+ </> : null}
161
+ </Stack></Container></main>
162
+ ```
163
+
164
+ ```tsx
165
+ // Native
166
+ import { ScrollView, useWindowDimensions } from "react-native";
167
+ import { spacing } from "@hjmds/design-contracts/foundations";
168
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
169
+ import { Container, Stack, Surface, Text } from "@hjmds/react-native/primitives";
170
+ import { Heading } from "@hjmds/react-native/heading";
171
+ import { Button } from "@hjmds/react-native/actions";
172
+ import { SegmentedControl } from "@hjmds/react-native/inputs";
173
+ import { EmptyState, Notice, Skeleton } from "@hjmds/react-native/feedback";
174
+ import { AnimatedStatistic } from "@hjmds/react-native/statistic-motion";
175
+ import { ActivityHeatmap } from "@hjmds/react-native/activity-heatmap";
176
+ import { List, ListRow } from "@hjmds/react-native/data-display";
177
+
178
+ // periods·periodKey·errorKey·viewToggleKey는 Web과 같다.
179
+ const { width } = useWindowDimensions();
180
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
181
+
182
+ <ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
183
+ <Container size="content" gutter={gutter}><Stack gap="xl">
184
+ <Heading level="level3" semanticLevel={1}>{t("dashboard.title")}</Heading>
185
+ <Text>{t("dashboard.intro")}</Text>
186
+ <SegmentedControl label={t("dashboard.period")} presentation="pills"
187
+ items={periods.map(id => ({ value: id, label: t(periodKey[id]) }))} value={period} onValueChange={setPeriod} />
188
+ <Text accessibilityLiveRegion="polite">{t("dashboard.range", range)}</Text>
189
+ {error ? <Notice tone="danger" announcement="assertive" title={t(errorKey[error])}
190
+ action={<Button tone="secondary" size="small" loading={retrying} onPress={retry}>{t("common.retry")}</Button>} /> : null}
191
+ {loading && !data ? (
192
+ <Surface padding="lg"><Stack gap="lg">
193
+ {[0, 1, 2].map(i => <Stack key={i} gap="xxs">
194
+ <Skeleton shape="text" width="40%" {...(i === 0 ? { accessibilityLabel: t("dashboard.loading") } : {})} />
195
+ <Skeleton shape="block" width="30%" />
196
+ </Stack>)}
197
+ </Stack></Surface>
198
+ ) : data ? <>
199
+ <Surface padding="lg"><Stack gap="lg">
200
+ <AnimatedStatistic descriptor={{ id: "count", label: t("dashboard.count") }} value={data.count} locale={locale} />
201
+ {/* activeDays · minutes — Web과 같다 */}
202
+ </Stack></Surface>
203
+ {data.count ? <>
204
+ <Text emphasis="strong">{t("dashboard.activity")}</Text>
205
+ <Button tone="ghost" onPress={() => setList(v => !v)}>{t(viewToggleKey[list ? "list" : "grid"])}</Button>
206
+ <ActivityHeatmap descriptor={data.heatmap} label={t("dashboard.activity")} formatDay={formatDay} view={list ? "list" : "grid"} />
207
+ <List label={t("dashboard.records")}>{data.records.map(r =>
208
+ <ListRow key={r.id} title={r.title} description={t("dashboard.recordMeta", { date: r.date, minutes: r.minutes })} />)}</List>
209
+ </> : <EmptyState title={t("dashboard.empty.title")} description={t("dashboard.empty.body")}
210
+ action={<Button onPress={() => setPeriod("month")}>{t("dashboard.reset")}</Button>} />}
211
+ </> : null}
212
+ </Stack></Container>
213
+ </ScrollView>
214
+ ```
215
+
216
+ ## 큰 글자·다크·좁은 폭
217
+
218
+ | 조건 | 바뀌는 것 |
219
+ | --- | --- |
220
+ | 큰 글자(`textScale` 2) | 기간 선택은 큰 글자에서 세로로 쌓인다. Statistic 값은 Web에서 `type.title` 크기 × 글자 배율로 커지고 `overflow-wrap: anywhere`로 꺾인다. 긴 제목은 Heading이 줄바꿈한다 |
221
+ | 다크 | 테마 토큰만 쓰므로 바꿀 것이 없다 |
222
+ | 좁은 폭 | 구조 동일, gutter가 `compact` 16으로 준다. ActivityHeatmap 격자가 좁으면 보기 전환으로 목록을 쓴다 |
223
+ | 넓은 폭 Web | 한 열 유지, Container `content`가 1200으로 폭을 묶고 gutter `regular` 20 |
224
+
225
+ ## 플랫폼 차이
226
+
227
+ | 항목 | Web | Native |
228
+ | --- | --- | --- |
229
+ | 바깥 스크롤 | 문서 스크롤 | `ScrollView` 위아래 `spacing.lg` 20, 좌우는 안쪽 Container gutter |
230
+ | 상단 안전 영역 | 브라우저 | 화면이 소유하지 않는다. 내비게이션 헤더 또는 [TopBar](../components/top-bar.md) `safeAreaTop`이 맡고, 헤더 없이 띄울 때만 제품이 감싼다 |
231
+ | 기간 표시 알림 | `role="status"` | `accessibilityLiveRegion="polite"`(Android만), iOS는 제품이 직접 알림(함정 참고) |
232
+ | 로딩 알림 | 감싼 영역 `aria-busy` + `aria-label`(Skeleton은 항상 `aria-hidden`) | 첫 Skeleton `accessibilityLabel` 하나 |
233
+ | 오류 Notice 알림 | `tone="danger"`가 알림 영역 | `announcement="assertive"`를 명시(기본 `none`) |
234
+ | EmptyState 기본 위아래 여백 | `spacing.xxl` 32(`styles.css`) | `spacing.xxxl` 40(`emptyStateRecipe.density.regular`) |
235
+
236
+ ## 함정
237
+
238
+ - ActivityHeatmap은 1~366일만 받는다(범위 밖이면 `RangeError`). 1년 넘는 기록은 제품이 기간을 나눈다.
239
+ - 스토리의 `PatternStatus`는 showcase 전용이다. iOS 알림은 `AccessibilityInfo.announceForAccessibilityWithOptions(text, { queue: true })`로
240
+ 제품이 두되, **기간 문구가 이전과 달라졌을 때만** 보내고 첫 렌더·같은 문구 재렌더·빈 문자열·앱이 active가 아닐 때(`AppState.currentState`)는
241
+ 보내지 않는다(`showcase/native/src/pattern-status.tsx`).
242
+ - 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
243
+ Text의 기본 `emphasis="regular"`가 굵기를 덮어 24px 보통 굵기로 나오고(`.hjm-text[data-emphasis="regular"]`), Native는 제목 단계가 없다.
244
+ [Text](../components/text.md) 규칙대로 새 제품은 Heading `level3`·`semanticLevel={1}`(24/32 heavy)을 쓴다.
245
+ - 예제는 양 플랫폼 모두 Container로 감싼다. 예제의 compact gutter 16과 제품이 폭에 따라 선택하는 regular gutter 20을 구분한다.
@@ -0,0 +1,306 @@
1
+ # 작품 탐색
2
+
3
+ - 단계: 화면
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.12.1
7
+ - 검토일: 2026-10-06
8
+ - 근거: `showcase/web/src/patterns/DiscoveryGallery.stories.tsx`, `showcase/native/src/DiscoveryGallery.stories.tsx`, `showcase/shared/discovery-gallery.ts`, `src/base-recipes.ts`(`buttonRecipe.states.selected`), `src/grid.ts`
9
+ - 스토리북: `배포/화면/검색/작품 탐색`
10
+
11
+ ## 목적
12
+
13
+ 여러 사람의 작품(카드)을 검색·카테고리·정렬로 훑고, 마음에 드는 것을 저장하고, 하나를 시트로 크게 보는 갤러리 화면이다.
14
+ [검색 결과와 필터](common-search.md)가 "검색어로 찾아 조건으로 좁히기"라면 이 화면은 "남의 것 둘러보기"라 결과를 목록이 아니라 그림 카드 격자로 보인다.
15
+ 카드 그림(`Thumbnail`)·작가·좋아요 수는 모두 제품 소유이며 스토리의 그림은 직접 그린 예시다.
16
+
17
+ ## 영역 구조
18
+
19
+ ```text
20
+ 좁은 폭(폭 < 600, Native·모바일 Web) — 세로 스크롤, 카드 한 열
21
+ ┌ 상단 안전 영역 (화면 소유 아님: 헤더·TopBar safeAreaTop) ┐
22
+ │ ScrollView 위아래 spacing.lg 20 │
23
+ │ Container gutter: 폭<600 compact 16 · 이상 regular 20 │
24
+ │ ① 머리 Stack gap md 16 │
25
+ │ eyebrow Text tone=brand │
26
+ │ 제목 Heading level3 · semanticLevel 1 │
27
+ │ 소개 Text │
28
+ │ 작품 검색 (보이는 라벨) │
29
+ │ [🔍 모바일, 작업 공간 ........... (x)] │ SearchField 최소 44
30
+ │ ↕ spacing.xl 24 │
31
+ │ ② [전체] [웹사이트] [모바일] [대시보드] │ ghost, 선택된 것만 selected
32
+ │ ③ [정렬: 인기순] [저장한 작품 2] │ secondary(정렬) · ghost+selected(저장만 보기)
33
+ │ ④ "N개의 작품" (상태 알림) │
34
+ │ (오류면 여기 Notice danger + [다시 시도]) │
35
+ │ ⑤ 카드 Grid │
36
+ │ ┌ article ───────────────────────────────────────────┐ │
37
+ │ │ ┌ 그림 비율 1.6, radius.lg ──────────────────────┐ │ │ Stack gap sm 12
38
+ │ │ └─────────────────────────────────────────────────┘ │ │
39
+ │ │ 제목 Text variant=label │ │
40
+ │ │ 작가 · 카테고리 (muted) │ │
41
+ │ │ [자세히 보기] [저장] │ │ ghost · secondary(+selected)
42
+ │ └──────────────────────────────────────────────────────┘ │
43
+ │ ↕ 카드 사이 spacing.xl 24 (두 플랫폼 공통) │
44
+ │ (0개면 EmptyState + [필터 초기화]) │
45
+ │ ⑥ 범위 안내 Text tone=muted (스토리 전용) │
46
+ └ 아래: 고정 영역 없음, 내용이 스크롤된다 ───────────────────────┘
47
+
48
+ 넓은 폭 — Grid columns { compact 1, medium 2, expanded 3 }, minColumnWidth 320
49
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
50
+ │ 카드 │ │ 카드 │ │ 카드 │ 열·행 사이 spacing.xl 24
51
+ └──────────┘ └──────────┘ └──────────┘ Container content 1200 안에서 최대 3열
52
+
53
+ ⑦ 상세 Sheet(자세히 보기)
54
+ ┌ 작품 제목 ───────────────────────── (닫기) ┐ 머리 고정
55
+ │ 그림(1.6) │ 본문 스크롤, 직계 사이 spacing.lg 20
56
+ │ 작가 · 카테고리 │
57
+ │ 설명 │
58
+ ├───────────────────────────────────────────┤
59
+ │ footer: [ 컬렉션에 저장 ] │ 고정. Native 꽉 찬 폭, Web 끝 정렬
60
+ └ 하단 안전 영역 (Sheet 소유) ─────────────────┘
61
+ ```
62
+
63
+ | 영역 | 컴포넌트 | 위치 | 크기·간격 |
64
+ | --- | --- | --- | --- |
65
+ | 바깥 틀 | Web `main` > [Container](../components/container.md) `size="content"` `gutter` > Stack `gap="xl"`, 문서 스크롤 · Native `ScrollView` `automaticallyAdjustKeyboardInsets` `keyboardShouldPersistTaps="handled"` > Container `size="content"` `gutter` > Stack `gap="xl"` | 화면 전체, 세로 스크롤 하나 | 좌우 Container gutter: 폭 < 600 `compact` 16, 이상 `regular` 20([화면 여백](../tokens/layout.md)). Native ScrollView 위아래 `spacing.lg` 20(`paddingVertical`). 직계 요소 사이 `spacing.xl` 24. 위 안전 영역은 내비게이션 헤더·[TopBar](../components/top-bar.md) `safeAreaTop`이 맡는다. 아래 고정 영역이 없다. 키보드가 떠 있으면 Native ScrollView가 inset을 늘리고 첫 탭으로 필터·카드 버튼이 눌린다 |
66
+ | ① 머리 | Stack `gap="md"` > Text(brand) · [Heading](../components/heading.md) `level="level3"` `semanticLevel={1}` · Text(소개) · SearchField | 맨 위(카드 배경 없음) | 요소 사이 `spacing.md` 16. SearchField는 두 플랫폼 모두 보이는 `label` |
67
+ | ② 카테고리 | Stack `axis="inline" wrap gap="sm"` > SegmentedControl `presentation="pills"` | 머리 아래 | 높이 44, 사이 `spacing.sm` 12 |
68
+ | ③ 정렬·저장 보기 | Stack `axis="inline" wrap gap="sm"` > Button `secondary` · `ghost` + `selected` | 카테고리 아래 | 사이 12 |
69
+ | ④ 결과 수 | Text `role="status"`(Web) · `accessibilityLiveRegion="polite"` Text(Native) | 정렬 줄 아래 | — |
70
+ | ⑤ 격자 | [Grid](../components/grid.md) `columns={{ compact: 1, medium: 2, expanded: 3 }}` `gap={{ compact: "xl" }}` `minColumnWidth={{ compact: 320 }}` | 결과 수 아래 | 열·행 사이 `spacing.xl` 24(두 플랫폼 공통). 열 수는 창 폭 class로 고르고, 본문이 좁으면 `minColumnWidth` 320이 열을 줄인다 |
71
+ | ⑤ 카드 | `article`(Web) > Stack `gap="sm"` > 그림·Text·버튼 줄 | 격자 칸 | 그림 비율 1.6([AspectRatio](../components/aspect-ratio.md) `ratio={1.6}`), 모서리 `radius.lg` 16, 카드 안 `spacing.sm` 12 |
72
+ | ⑦ 상세 | [Sheet](../components/sheet.md)(Native `scrollable`) > Stack `gap="lg"` + `footer` | 오버레이, 화면 아래 | 본문 직계 요소(그림·작가 · 카테고리 Text·설명 Text) 사이 모두 `spacing.lg` 20, 메타는 기본 tone. 좌우 `spacing.lg` 20, Web 최대 폭 640. 저장은 `footer`(고정) |
73
+
74
+ ## 버튼과 행동 위치
75
+
76
+ | 행동 | 컴포넌트·tone | 위치 | 개수·순서 |
77
+ | --- | --- | --- | --- |
78
+ | 검색 | SearchField `onValueChange` | ① 맨 아래 | 1. 입력 즉시 거른다 |
79
+ | 카테고리 | Button `ghost`, 선택된 것만 `selected` | ② | 카테고리 수, "전체"가 맨 앞 |
80
+ | 정렬 전환 | Button `secondary`, 라벨에 현재 값(`t("gallery.sort", { value: t(sortKey[sort]) })`) | ③ 첫 자리 | 1. 누를 때마다 인기순 ↔ 최신순 |
81
+ | 저장한 작품만 | SegmentedControl `presentation="pills"`, 라벨에 개수 | ③ 정렬 뒤 | 1 |
82
+ | 상세 보기 | Button `ghost` | 카드 맨 아래 버튼 줄 첫 자리 | 카드마다 1. `aria-label`/`accessibilityLabel`에 작품 제목 포함 |
83
+ | 카드 저장 | Button `secondary` + `selected`(토글) | 카드 버튼 줄 둘째 자리 | 카드마다 1. 라벨 "저장" ↔ "저장됨", 접근성 이름에 제목과 저장/저장 취소 |
84
+ | 시트 저장(시트의 주 행동) | Button 기본 primary + `selected` | ⑦ Sheet `footer`(Native 꽉 찬 폭, Web 끝 정렬) | 1. 라벨 "컬렉션에 저장" ↔ "저장 취소". 시트를 닫지 않는 토글이다 |
85
+ | 빈 결과 복구 | EmptyState `action` > Button(기본 primary) | EmptyState 맨 아래 | 1. 검색어·카테고리·저장만 보기를 초기화(정렬은 유지) |
86
+ | 다시 시도 | [Notice](../components/notice.md) `action` > Button `tone="secondary" size="small"` | ④ 아래 Notice 끝 | 1. 오류일 때만 |
87
+ | 파괴 행동 | — | — | 없음(저장 취소는 같은 버튼의 토글) |
88
+
89
+ primary는 화면에서 빈 결과 복구 하나, 시트 안에서 저장 하나다. 시트 안 행동은 그 표면 안에서 세고, `selected`를 준 버튼은
90
+ tone과 관계없이 선택 모양(배경 `bg`, 글자·테두리 `contentBrand`)으로 칠해져 세지 않는다([Button](../components/button.md) "꼭 지킬 것").
91
+ 카드 안 버튼은 [보기][저장] 순서로 시작 정렬, `wrap`이다. 카드 전체를 누름 대상으로 만들지 않는다 — 카드 안에 버튼이 둘이라
92
+ 겹친 누름 대상이 된다.
93
+
94
+ ## 상태
95
+
96
+ | 상태 | 화면 모습 | 행동 |
97
+ | --- | --- | --- |
98
+ | 기본 | "전체"·인기순, 전체 카드, 결과 수 | 검색·필터·정렬·저장·보기 |
99
+ | 로딩 | 스토리에 없음. ①~③은 그대로, ⑤ 자리에 같은 Grid로 카드 3개 모양의 [Skeleton](../components/skeleton.md)(AspectRatio 1.6 안 `shape="block"` `height="100%"` + 제목 `shape="text"` 60%). 로딩 사실은 영역 단위로 한 번(Web Grid `aria-busy`·`aria-label`, Native 첫 Skeleton `accessibilityLabel`). 검색 중이면 SearchField Web `loading` · Native `busy` + `busyLabel` | 검색·필터는 그대로 |
100
+ | 빈 | ⑤ 자리에 EmptyState(`t("gallery.empty.title")`, 설명) | [필터 초기화] |
101
+ | 오류 | 스토리에 없음. ①~③은 남기고 ④ 아래 [Notice](../components/notice.md) `tone="danger"`(Native `announcement="assertive"` — 기본 `none`). 문구는 원인별 키(`errorKey`: 네트워크 `gallery.error.network`, 서버 `gallery.error.server`). 이전 결과가 있으면 ⑤를 유지하고, 결과가 없으면 EmptyState를 그리지 않는다(빈 결과와 실패를 섞지 않는다). 다시 시도가 또 실패하면 같은 Notice를 같은 자리에 두고 버튼 `loading`만 푼다 | Notice `action` 다시 시도 1개 |
102
+ | 저장 토글 | 카드 버튼이 `selected`로 바뀌고 ③ 저장 개수가 늘어난다. 저장만 보기 중 저장 취소하면 카드가 사라지고 결과 수가 읽힌다 | — |
103
+ | 상세 열림 | 시트가 올라오고 같은 그림을 크게 보인다. 시트에서 저장하면 카드 상태도 같이 바뀐다 | 저장·닫기 |
104
+
105
+ ## 사용하는 지침
106
+
107
+ | 지침 | 쓰는 곳 |
108
+ | --- | --- |
109
+ | [Container](../components/container.md) | 바깥 틀 폭·좌우 여백 |
110
+ | [Heading](../components/heading.md) | ① 화면 제목 |
111
+ | [SearchField](../components/search-field.md) | ① 검색 |
112
+ | [Button](../components/button.md) | 카테고리·정렬·저장 보기·카드 행동·시트 저장·다시 시도 |
113
+ | [Stack](../components/stack.md) | 세로 리듬, 버튼 줄, 카드 안 |
114
+ | [Text](../components/text.md) | 머리 문구, 카드 제목·메타 |
115
+ | [Grid](../components/grid.md) | ⑤ 카드 격자 |
116
+ | [AspectRatio](../components/aspect-ratio.md) · [Image](../components/image.md) | 제품의 실제 썸네일, 로딩 Skeleton 틀 |
117
+ | [EmptyState](../components/empty-state.md) | 결과 없음 |
118
+ | [Skeleton](../components/skeleton.md) | 로딩 |
119
+ | [Notice](../components/notice.md) | 오류 |
120
+ | [Sheet](../components/sheet.md) | ⑦ 상세 |
121
+ | [화면 여백과 너비](../tokens/layout.md) | gutter·최대 폭·breakpoint |
122
+ | [간격](../tokens/spacing.md) | `spacing.sm`·`md`·`lg`·`xl` |
123
+
124
+ ## 코드 골격
125
+
126
+ 카테고리·작품·썸네일·문구는 제품 소유다. `Thumbnail`은 제품 컴포넌트 자리다(AspectRatio 1.6 + Image). 상태마다 다른 문구는
127
+ 상태→키 상수 표로 고른다. 아래 두 예는 설치 버전 타입으로 검사했다(`query`·`results`·`selected` 등 상태 값과 핸들러는 제품이 둔다).
128
+
129
+ ```tsx
130
+ // Web
131
+ import { AspectRatio, Container, Grid, Stack, Text } from "@hjmds/react/layout";
132
+ import { Heading } from "@hjmds/react/heading";
133
+ import { Button } from "@hjmds/react/actions";
134
+ import { SegmentedControl } from "@hjmds/react/selection";
135
+ import { SearchField } from "@hjmds/react/forms";
136
+ import { Sheet } from "@hjmds/react/overlays";
137
+ import { EmptyState, Notice, Skeleton } from "@hjmds/react/feedback";
138
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
139
+
140
+ type Category = "all" | "web" | "mobile" | "dashboard";
141
+ type Sort = "popular" | "latest";
142
+ type LoadError = "network" | "server";
143
+ const categories: readonly Category[] = ["all", "web", "mobile", "dashboard"];
144
+ const categoryKey = { all: "gallery.category.all", web: "gallery.category.web", mobile: "gallery.category.mobile", dashboard: "gallery.category.dashboard" } as const satisfies Record<Category, string>;
145
+ const sortKey = { popular: "gallery.sort.popular", latest: "gallery.sort.latest" } as const satisfies Record<Sort, string>;
146
+ const errorKey = { network: "gallery.error.network", server: "gallery.error.server" } as const satisfies Record<LoadError, string>;
147
+ const gutter = resolveWindowClass(window.innerWidth) === "compact" ? "compact" : "regular";
148
+ const gridProps = { columns: { compact: 1, medium: 2, expanded: 3 }, gap: { compact: "xl" }, minColumnWidth: { compact: 320 } } as const;
149
+
150
+ <main><Container size="content" gutter={gutter}><Stack gap="xl">
151
+ <Stack gap="md">
152
+ <Text tone="brand">{t("gallery.eyebrow")}</Text>
153
+ <Heading level="level3" semanticLevel={1}>{t("gallery.title")}</Heading>
154
+ <Text as="p">{t("gallery.intro")}</Text>
155
+ <SearchField label={t("gallery.search")} clearLabel={t("gallery.clear")} loading={loading} value={query} onValueChange={setQuery} />
156
+ </Stack>
157
+ <SegmentedControl label={t("gallery.category")} presentation="pills"
158
+ items={categories.map(id => ({ value: id, label: t(categoryKey[id]) }))} value={category} onValueChange={setCategory} />
159
+ <Stack axis="inline" wrap gap="sm">
160
+ <Button tone="secondary" onClick={toggleSort}>{t("gallery.sort", { value: t(sortKey[sort]) })}</Button>
161
+ <Button tone="ghost" selected={savedOnly} onClick={() => setSavedOnly(v => !v)}>{t("gallery.savedOnly", { count: savedCount })}</Button>
162
+ </Stack>
163
+ <Text role="status">{loading ? "" : t("gallery.count", { count: results.length })}</Text>
164
+ {error ? <Notice tone="danger" title={t(errorKey[error])}
165
+ action={<Button tone="secondary" size="small" loading={retrying} onClick={retry}>{t("common.retry")}</Button>} /> : null}
166
+ {loading && results.length === 0 ? (
167
+ <Grid {...gridProps} aria-busy="true" aria-label={t("gallery.loading")}>
168
+ {[0, 1, 2].map(i => <Stack key={i} gap="sm"><AspectRatio ratio={1.6} aria-hidden="true"><Skeleton shape="block" height="100%" /></AspectRatio><Skeleton shape="text" width="60%" /></Stack>)}
169
+ </Grid>
170
+ ) : results.length ? (
171
+ <Grid {...gridProps}>
172
+ {results.map(item => <article key={item.id}><Stack gap="sm">
173
+ <Thumbnail item={item} />
174
+ <Text variant="label">{item.title}</Text>
175
+ <Text tone="muted">{t("gallery.meta", { author: item.author, category: item.categoryLabel })}</Text>
176
+ <Stack axis="inline" wrap gap="sm">
177
+ <Button tone="ghost" aria-label={t("gallery.viewNamed", { title: item.title })} onClick={() => setSelected(item)}>{t("gallery.details")}</Button>
178
+ <Button tone="secondary" selected={isSaved(item)} onClick={() => toggleSaved(item.id)}
179
+ aria-label={t(isSaved(item) ? "gallery.unsaveNamed" : "gallery.saveNamed", { title: item.title })}>
180
+ {t(isSaved(item) ? "gallery.saved" : "gallery.save")}</Button>
181
+ </Stack>
182
+ </Stack></article>)}
183
+ </Grid>
184
+ ) : error ? null : <EmptyState title={t("gallery.empty.title")} description={t("gallery.empty.body")}
185
+ action={<Button onClick={reset}>{t("gallery.reset")}</Button>} />}
186
+ <Sheet open={selected !== null} onOpenChange={o => { if (!o) setSelected(null); }}
187
+ title={selected?.title ?? t("gallery.detail")} closeLabel={t("common.close")}
188
+ footer={selected ? <Button selected={isSaved(selected)} onClick={() => toggleSaved(selected.id)}
189
+ aria-label={t(isSaved(selected) ? "gallery.unsaveNamed" : "gallery.saveNamed", { title: selected.title })}>
190
+ {t(isSaved(selected) ? "gallery.unsave" : "gallery.saveToCollection")}</Button> : null}>
191
+ {selected ? <Stack gap="lg">
192
+ <Thumbnail item={selected} />
193
+ <Text>{t("gallery.meta", { author: selected.author, category: selected.categoryLabel })}</Text>
194
+ <Text as="p">{selected.description}</Text>
195
+ </Stack> : null}
196
+ </Sheet>
197
+ </Stack></Container></main>
198
+ ```
199
+
200
+ ```tsx
201
+ // Native
202
+ import { ScrollView, useWindowDimensions } from "react-native";
203
+ import { spacing } from "@hjmds/design-contracts/foundations";
204
+ import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
205
+ import { AspectRatio, Container, Grid, Stack, Text } from "@hjmds/react-native/primitives";
206
+ import { Heading } from "@hjmds/react-native/heading";
207
+ import { Button } from "@hjmds/react-native/actions";
208
+ import { SegmentedControl } from "@hjmds/react-native/inputs";
209
+ import { SearchField } from "@hjmds/react-native/inputs";
210
+ import { Sheet } from "@hjmds/react-native/overlays";
211
+ import { EmptyState, Notice, Skeleton } from "@hjmds/react-native/feedback";
212
+
213
+ // categories·categoryKey·sortKey·errorKey는 Web과 같다.
214
+ const { width } = useWindowDimensions();
215
+ const gutter = resolveWindowClass(width) === "compact" ? "compact" : "regular";
216
+ const gridProps = { columns: { compact: 1, medium: 2, expanded: 3 }, gap: { compact: "xl" }, minColumnWidth: { compact: 320 } } as const;
217
+
218
+ <ScrollView automaticallyAdjustKeyboardInsets keyboardShouldPersistTaps="handled"
219
+ contentContainerStyle={{ paddingVertical: spacing.lg }}>
220
+ <Container size="content" gutter={gutter}><Stack gap="xl">
221
+ <Stack gap="md">
222
+ <Text tone="brand">{t("gallery.eyebrow")}</Text>
223
+ <Heading level="level3" semanticLevel={1}>{t("gallery.title")}</Heading>
224
+ <Text>{t("gallery.intro")}</Text>
225
+ <SearchField label={t("gallery.search")} clearLabel={t("gallery.clear")} busy={loading} busyLabel={t("gallery.searching")}
226
+ value={query} onValueChange={setQuery} />
227
+ </Stack>
228
+ <SegmentedControl label={t("gallery.category")} presentation="pills"
229
+ items={categories.map(id => ({ value: id, label: t(categoryKey[id]) }))} value={category} onValueChange={setCategory} />
230
+ <Stack axis="inline" wrap gap="sm">
231
+ <Button tone="secondary" onPress={toggleSort}>{t("gallery.sort", { value: t(sortKey[sort]) })}</Button>
232
+ <Button tone="ghost" selected={savedOnly} onPress={() => setSavedOnly(v => !v)}>{t("gallery.savedOnly", { count: savedCount })}</Button>
233
+ </Stack>
234
+ <Text accessibilityLiveRegion="polite">{loading ? "" : t("gallery.count", { count: results.length })}</Text>
235
+ {error ? <Notice tone="danger" announcement="assertive" title={t(errorKey[error])}
236
+ action={<Button tone="secondary" size="small" loading={retrying} onPress={retry}>{t("common.retry")}</Button>} /> : null}
237
+ {loading && results.length === 0 ? (
238
+ <Grid {...gridProps}>
239
+ {[0, 1, 2].map(i => <Stack key={i} gap="sm">
240
+ <AspectRatio ratio={1.6}><Skeleton shape="block" layoutStyle={{ flex: 1 }} {...(i === 0 ? { accessibilityLabel: t("gallery.loading") } : {})} /></AspectRatio>
241
+ <Skeleton shape="text" width="60%" />
242
+ </Stack>)}
243
+ </Grid>
244
+ ) : results.length ? (
245
+ <Grid {...gridProps}>
246
+ {results.map(item => <Stack key={item.id} gap="sm">
247
+ <Thumbnail item={item} />
248
+ <Text variant="label">{item.title}</Text>
249
+ <Text tone="muted">{t("gallery.meta", { author: item.author, category: item.categoryLabel })}</Text>
250
+ <Stack axis="inline" wrap gap="sm">
251
+ <Button tone="ghost" accessibilityLabel={t("gallery.viewNamed", { title: item.title })} onPress={() => setSelected(item)}>{t("gallery.details")}</Button>
252
+ <Button tone="secondary" selected={isSaved(item)} onPress={() => toggleSaved(item.id)}
253
+ accessibilityLabel={t(isSaved(item) ? "gallery.unsaveNamed" : "gallery.saveNamed", { title: item.title })}>
254
+ {t(isSaved(item) ? "gallery.saved" : "gallery.save")}</Button>
255
+ </Stack>
256
+ </Stack>)}
257
+ </Grid>
258
+ ) : error ? null : <EmptyState title={t("gallery.empty.title")} description={t("gallery.empty.body")}
259
+ action={<Button onPress={reset}>{t("gallery.reset")}</Button>} />}
260
+ <Sheet scrollable open={selected !== null} onOpenChange={o => { if (!o) setSelected(null); }}
261
+ title={selected?.title ?? t("gallery.detail")} closeLabel={t("common.close")}
262
+ footer={selected ? <Button fullWidth selected={isSaved(selected)} onPress={() => toggleSaved(selected.id)}
263
+ accessibilityLabel={t(isSaved(selected) ? "gallery.unsaveNamed" : "gallery.saveNamed", { title: selected.title })}>
264
+ {t(isSaved(selected) ? "gallery.unsave" : "gallery.saveToCollection")}</Button> : null}>
265
+ {selected ? <Stack gap="lg">
266
+ <Thumbnail item={selected} />
267
+ <Text>{t("gallery.meta", { author: selected.author, category: selected.categoryLabel })}</Text>
268
+ <Text>{selected.description}</Text>
269
+ </Stack> : null}
270
+ </Sheet>
271
+ </Stack></Container>
272
+ </ScrollView>
273
+ ```
274
+
275
+ ## 큰 글자·다크·좁은 폭
276
+
277
+ | 조건 | 바뀌는 것 |
278
+ | --- | --- |
279
+ | 큰 글자(`textScale` 2) | 필터·정렬·카드 버튼 줄이 감긴다. 시트 본문이 길어져도 저장은 `footer`라 스크롤 밖으로 밀리지 않는다. 카드 그림 안 장식 글자는 그림의 일부라 Native에서 `allowFontScaling={false}`로 고정하고, 읽어야 하는 제목·작가는 그림 밖 Text로 둔다 |
280
+ | 다크 | 그림·카드 색이 `primary`·`surfaceAccent`·`surfaceAlt`·`bg` 테마 토큰이라 같이 바뀐다 |
281
+ | 좁은 폭 | 카드 한 열(`compact`), gutter `compact` 16 |
282
+ | 넓은 폭 | 600 이상 두 열, 960 이상 세 열. 본문 폭이 칸마다 320을 못 주면 `minColumnWidth`가 열을 줄인다 |
283
+
284
+ ## 플랫폼 차이
285
+
286
+ | 항목 | Web | Native |
287
+ | --- | --- | --- |
288
+ | 격자 | Grid(폭은 `ResizeObserver`로 잰다), 사이 `spacing.xl` 24 | Grid(폭은 `onLayout`으로 잰다), 사이 `spacing.xl` 24 |
289
+ | 그림 접근성 | `aria-hidden="true"` | `accessibilityElementsHidden` + `importantForAccessibility="no-hide-descendants"` |
290
+ | 결과 수 알림 | `role="status"` | 라이브 영역(Android) + iOS는 제품이 직접 알림(문구가 바뀌었을 때만, `AppState` active일 때만 — [대시보드](dashboard.md) 함정) |
291
+ | 검색 진행 | `loading` | `busy` + `busyLabel`(필수) |
292
+ | 시트 footer | 오른쪽 정렬 가로 줄 | 세로 열, `fullWidth` |
293
+ | 오류 Notice 알림 | `tone="danger"`가 알림 영역 | `announcement="assertive"`를 명시(기본 `none`) |
294
+
295
+ ## 함정
296
+
297
+ - 카드 그림은 장식으로 숨기므로 그림에만 있는 정보(점수·제목)가 없어야 한다.
298
+ - Grid `gap`은 단일 token 문자열을 받지 않는다. `gap={{ compact: "xl" }}`처럼 class별 값으로 준다(`ResponsiveValue<GridGap>`).
299
+ - 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
300
+ 새 제품은 Heading `level3`·`semanticLevel={1}`을 쓴다.
301
+ - 예제의 바깥 틀은 양 플랫폼 모두 `Container gutter="compact"`다. 제품의 넓은 화면에서는 위 배치 표의 반응형 gutter를 선택한다.
302
+ - 양 플랫폼 격자는 Grid를 사용한다. 창 폭의 열 수가 본문보다 넓으면 `minColumnWidth`가 실제 열 수를 줄인다.
303
+ - 카테고리는 `SegmentedControl presentation="pills"`의 단일 선택 계약을 사용한다. 저장만 보기는 독립 토글이므로 Button `selected`를 유지한다.
304
+ - 시트 확정 행동은 footer에 둔다. Native는 scrollable 본문과 분리해 큰 글자에서도 접근 가능하게 한다.
305
+ - 현재 스토리의 카드 메타는 "작가 · 카테고리 · 좋아요 N"을 한국어 템플릿으로 조립한다. 제품은 `t("gallery.meta", { author, category })`처럼
306
+ 보간 키 하나로 둔다. 좋아요 수가 필요하면 같은 키에 값을 더한다.