@hjmds/design-contracts 1.30.0 → 1.32.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 (102) hide show
  1. package/dist/behaviors.d.ts +3 -0
  2. package/dist/behaviors.d.ts.map +1 -1
  3. package/dist/bottom-navigation.d.ts +43 -0
  4. package/dist/bottom-navigation.d.ts.map +1 -1
  5. package/dist/bottom-navigation.js +55 -0
  6. package/dist/bottom-navigation.js.map +1 -1
  7. package/dist/carousel.d.ts +30 -0
  8. package/dist/carousel.d.ts.map +1 -1
  9. package/dist/carousel.js +32 -0
  10. package/dist/carousel.js.map +1 -1
  11. package/dist/catalog.d.ts +178 -1
  12. package/dist/catalog.d.ts.map +1 -1
  13. package/dist/catalog.js +16 -6
  14. package/dist/catalog.js.map +1 -1
  15. package/dist/component-recipes.d.ts +126 -0
  16. package/dist/component-recipes.d.ts.map +1 -1
  17. package/dist/component-recipes.js +71 -2
  18. package/dist/component-recipes.js.map +1 -1
  19. package/dist/icon.d.ts +58 -1
  20. package/dist/icon.d.ts.map +1 -1
  21. package/dist/icon.js +69 -0
  22. package/dist/icon.js.map +1 -1
  23. package/dist/image-choice.d.ts +47 -0
  24. package/dist/image-choice.d.ts.map +1 -1
  25. package/dist/image-choice.js +56 -0
  26. package/dist/image-choice.js.map +1 -1
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/pagination.d.ts +53 -0
  32. package/dist/pagination.d.ts.map +1 -1
  33. package/dist/pagination.js +48 -0
  34. package/dist/pagination.js.map +1 -1
  35. package/dist/recipes.d.ts +1 -1
  36. package/dist/recipes.d.ts.map +1 -1
  37. package/dist/recipes.js.map +1 -1
  38. package/dist/screen-patterns.d.ts +152 -2
  39. package/dist/screen-patterns.d.ts.map +1 -1
  40. package/dist/screen-patterns.js +132 -4
  41. package/dist/screen-patterns.js.map +1 -1
  42. package/dist/showcase.d.ts.map +1 -1
  43. package/dist/showcase.js +3 -0
  44. package/dist/showcase.js.map +1 -1
  45. package/dist/steps.d.ts +16 -7
  46. package/dist/steps.d.ts.map +1 -1
  47. package/dist/steps.js +6 -1
  48. package/dist/steps.js.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/dist/version.js.map +1 -1
  52. package/docs/authoring-tools.md +1 -0
  53. package/docs/bottom-navigation.md +28 -0
  54. package/docs/carousel.md +8 -0
  55. package/docs/data-workflows.md +1 -0
  56. package/docs/dialog.md +23 -0
  57. package/docs/generated/component-maturity.md +7 -4
  58. package/docs/generated/renderer-evidence.json +317 -19
  59. package/docs/generated/renderer-evidence.md +9 -3
  60. package/docs/generated/showcase-manifest.json +164 -6
  61. package/docs/icon.md +9 -0
  62. package/docs/pagination.md +17 -0
  63. package/docs/screen-patterns.md +55 -1
  64. package/docs/steps.md +4 -3
  65. package/docs/toast.md +10 -0
  66. package/docs/usage/README.md +5 -2
  67. package/docs/usage/SELECTION.md +1 -1
  68. package/docs/usage/components/auth-screen-layout.md +15 -5
  69. package/docs/usage/components/bottom-cta.md +11 -0
  70. package/docs/usage/components/bottom-navigation.md +99 -2
  71. package/docs/usage/components/card.md +36 -5
  72. package/docs/usage/components/carousel.md +1 -0
  73. package/docs/usage/components/chat-date-separator.md +86 -0
  74. package/docs/usage/components/chat-message.md +13 -1
  75. package/docs/usage/components/chat-screen.md +3 -2
  76. package/docs/usage/components/comment-thread-row.md +11 -3
  77. package/docs/usage/components/comment-thread-screen.md +1 -1
  78. package/docs/usage/components/dialog.md +69 -0
  79. package/docs/usage/components/editor-screen.md +6 -2
  80. package/docs/usage/components/field.md +13 -0
  81. package/docs/usage/components/icon-tile.md +19 -3
  82. package/docs/usage/components/icon.md +45 -6
  83. package/docs/usage/components/image-choice-group.md +35 -4
  84. package/docs/usage/components/media-selection-screen.md +20 -1
  85. package/docs/usage/components/message-composer.md +4 -1
  86. package/docs/usage/components/moderation-screen.md +107 -2
  87. package/docs/usage/components/page-indicator.md +102 -0
  88. package/docs/usage/components/pagination.md +28 -4
  89. package/docs/usage/components/profile-screen.md +9 -3
  90. package/docs/usage/components/profile-summary.md +3 -3
  91. package/docs/usage/components/screen-layout.md +15 -3
  92. package/docs/usage/components/search-screen.md +5 -3
  93. package/docs/usage/components/steps.md +14 -7
  94. package/docs/usage/components/tabs.md +21 -1
  95. package/docs/usage/components/text.md +30 -1
  96. package/docs/usage/components/toast.md +5 -0
  97. package/docs/usage/components/typing-indicator.md +89 -0
  98. package/docs/usage/screens/common-login.md +3 -1
  99. package/docs/usage/screens/common-profile.md +3 -3
  100. package/docs/usage/screens/flow-editor.md +13 -2
  101. package/docs/usage/screens/flow-moderation.md +31 -3
  102. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-16
7
+ - 검토일: 2026-10-18
8
8
  - 근거: [Pagination](../../pagination.md), `src/pagination.ts`(`paginationRecipe`)
9
9
  - 스토리북: `배포/컴포넌트/탐색/페이지 이동`
10
10
 
@@ -13,11 +13,14 @@
13
13
  총 개수(또는 총 페이지 수)가 정해진 결과 집합에서 사용자가 **임의의 페이지로 바로 이동**해야 할 때
14
14
  Web·Native에서 쓴다. 검색 결과, 관리자 테이블, 기록 목록이 여기에 속한다.
15
15
 
16
+ 총량은 모르지만 결과가 **페이지 단위로 오고 다음 페이지가 있는지만 아는** 원천(Kakao Local `meta.is_end`, cursor API의
17
+ `hasMore`)도 Pagination을 쓴다. `descriptor={{ currentPage, hasNext }}`로 이전 · 현재 페이지 표시 · 다음만 그린다(1.32.0).
18
+
16
19
  ## 쓰지 않을 때
17
20
 
18
21
  | 상황 | 대신 쓸 것 |
19
22
  | --- | --- |
20
- | 총량을 모르거나 계속 이어지는 피드 | [LoadMore](load-more.md) |
23
+ | 계속 이어 붙이는 피드(이전 페이지로 돌아가지 않음) | [LoadMore](load-more.md) |
21
24
  | 끝없이 이어지는 Native 피드 | [LoadMore](load-more.md), [VirtualList](virtual-list.md) |
22
25
  | 단계형 흐름의 이전/다음 | [Steps](steps.md) |
23
26
  | 이미지·카드 넘기기 | [Carousel](carousel.md) |
@@ -65,6 +68,25 @@ Native는 같은 props를 받는다. 조회 중에는 `disabled`로 이전·번
65
68
 
66
69
  2026-10-15 다에리의 유한 편지 목록에서 페이지 번호가 이미 제품 기능이었으나 renderer 부재로 공통 배치를 복제한 원인을 수정했다. Native renderer는 1.26.0부터 게시본에 포함된다.
67
70
 
71
+ ### 전체 수를 모르는 결과 (1.32.0)
72
+
73
+ ```tsx
74
+ // Native (Web도 같은 props)
75
+ <Pagination
76
+ label={t("search.pagination")}
77
+ descriptor={{ currentPage: page, hasNext: results.data?.hasMore ?? false }}
78
+ labels={{ previous: t("previousPage"), next: t("nextPage") }}
79
+ composeCurrentPageLabel={({ page }) => t("search.page", { page })}
80
+ onPageChange={(next) => setPage(next)}
81
+ disabled={results.isFetching}
82
+ />
83
+ ```
84
+
85
+ 번호 목록은 그리지 않는다. 다음 페이지 너머는 주소를 알 수 없기 때문이다. 현재 페이지 표시는 버튼이 아닌 글자이고
86
+ `composeCurrentPageLabel`이 만든 문자열이 보이는 글자이자 접근성 이름이다(Web `aria-current="page"`, Native 선택 상태).
87
+ 첫 페이지면 이전, `hasNext: false`면 다음이 비활성이다. 한 페이지뿐이어도 두 단계가 비활성인 채로 그려진다.
88
+ `composeAccessibleName`과 함께 넘기거나 총량 필드와 섞으면 던진다.
89
+
68
90
  ## 축과 기본값
69
91
 
70
92
  타입은 `@hjmds/design-contracts/components/pagination`에서 가져온다.
@@ -72,10 +94,11 @@ Native는 같은 props를 받는다. 조회 중에는 `disabled`로 이전·번
72
94
  | prop | 값 | 기본값 | 설명 |
73
95
  | --- | --- | --- | --- |
74
96
  | `label` | 문자열 | 필수 | `<nav>` 이름. 비면 던진다 |
75
- | `descriptor` | `{ currentPage, totalCount, pageSize, siblingCount?, boundaryCount? }` 또는 `{ currentPage, totalPages, siblingCount?, boundaryCount? }` | 필수 | 정확히 하나의 형태. `currentPage`는 1부터 시작하는 정수 |
97
+ | `descriptor` | `{ currentPage, totalCount, pageSize, siblingCount?, boundaryCount? }` 또는 `{ currentPage, totalPages, siblingCount?, boundaryCount? }` 또는 `{ currentPage, hasNext: boolean }` | 필수 | 정확히 하나의 형태. `currentPage`는 1부터 시작하는 정수. `hasNext` 형태(1.32.0)는 총량을 모를 때 쓴다 |
76
98
  | `siblingCount` · `boundaryCount`(descriptor 안) | 정수 | 1 | 생략된 구간은 말줄임으로 표시한다 |
77
99
  | `labels` | `{ previous: string; next: string }` | 필수 | 이전·다음 버튼 문구 |
78
- | `composeAccessibleName` | `(info: { page: number; totalPages: number; current: boolean }) => string` | 필수 | 페이지 버튼마다 부른다. 어순은 제품 i18n이 정한다 |
100
+ | `composeAccessibleName` | `(info: { page: number; totalPages: number; current: boolean }) => string` | 총량 형태에서 필수 | 페이지 버튼마다 부른다. 어순은 제품 i18n이 정한다 |
101
+ | `composeCurrentPageLabel` | `(info: { page: number }) => string` | `hasNext` 형태에서 필수 | 1.32.0 추가. 현재 페이지 표시의 글자이자 접근성 이름(예: "3페이지") |
79
102
  | `onPageChange` | `(page: number, reason: "previous" \| "next" \| "page") => void` | 필수 | 제품이 `currentPage`를 바꾼다 |
80
103
  | `className` · `layoutStyle` | 문자열 · 배치 전용 style 객체 | — | 루트 배치만 |
81
104
 
@@ -87,6 +110,7 @@ Native는 같은 props를 받는다. 조회 중에는 `disabled`로 이전·번
87
110
  | 간격 | 칸 안쪽 `spacing.xs` 8, 칸 사이 `spacing.xxs` 4 | `paginationRecipe.gap`, `.hjm-pagination__list` |
88
111
  | 순서·정렬 | `[‹ 이전] [1] … [4] [5] [6] … [20] [다음 ›]`. 이전·다음은 아이콘 버튼(문구는 접근성 이름), RTL에서 화살표 반전. 정렬은 놓는 레이아웃이 정한다(관리자 테이블은 끝, 검색 결과는 가운데가 흔하다) | `src/pagination.tsx`, `.hjm-pagination__previous`·`__next` |
89
112
  | 고정·스크롤 | 목록·테이블 **바로 아래** 한 번. 페이지를 바꾸면 목록 시작으로 스크롤·포커스를 옮기는 것은 제품이 한다 | — |
113
+ | 전체 수를 모를 때 | `[‹ 이전] 3페이지 [다음 ›]`. 현재 페이지 표시는 높이 44(`paginationRecipe.pageLabel.minHeight`), 좌우 여백 8, `content.body` | `paginationRecipe.pageLabel`, `.hjm-pagination__page-label` |
90
114
  | 좁은 폭·큰 글자 | 폭이 모자라면 줄바꿈된다(`flex-wrap`). 좁은 폭에서는 `siblingCount`·`boundaryCount`를 0~1로 줄여 한 줄을 유지한다 | `.hjm-pagination__list` |
91
115
 
92
116
  ## 꼭 지킬 것
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.17.1
7
- - 검토일: 2026-10-06
7
+ - 검토일: 2026-10-18
8
8
  - 근거: [반복 화면 조합](../../screen-patterns.md), Web·Native `src/screens.tsx`·`src/screen-flows.tsx`; 기존 개별 지침을 새 규격으로 통합. 예제 스토리는 2026-10-06 사용자 승인으로 스토리북 배포([승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정)). 스토리북 배포는 API 게시가 아니다(`적용` 참고)
9
9
  - 스토리북: `배포/화면/계정/프로필`
10
10
 
@@ -67,7 +67,8 @@ import { ProfileScreen } from "@hjmds/react-native/screen-flows";
67
67
  | 슬롯·prop | 내용 |
68
68
  | --- | --- |
69
69
  | `summary` | 아바타·닉네임·소개 등 요약(필수). Avatar·Heading·Text를 제품이 조합한다 |
70
- | `edit` | 수정 진입 행동 `{ label, onAction, disabled?, pending? }`(필수). ghost 버튼으로 요약 아래에 놓인다 |
70
+ | `edit` | 수정 진입 행동 `{ label, onAction, disabled?, pending? }`(필수). 기본은 ghost 버튼으로 요약 아래에 놓인다 |
71
+ | `editPlacement` | 1.31.0. `"below-summary"`(기본) · `"summary"`. `summary`면 `edit`을 `summary` 안 [ProfileSummary](profile-summary.md)의 행동 줄 맨 앞에 `secondary` `small` 버튼으로 그린다(`profileSummaryRecipe.editAction`). 요약을 카드(Surface)로 감싸도 그 안의 ProfileSummary가 받는다. `summary`에 ProfileSummary가 없으면 수정 버튼이 그려지지 않는다 |
71
72
  | `children` | 활동·통계 등 본문 |
72
73
  | `accountActions` | 로그아웃·탈퇴 버튼 등. 본문 맨 아래 |
73
74
  | ScreenLayout props | `title`, `description`, `header`, `leading`, `actions`, `notice`, `state`, `stateAction` 등(`footer` 제외) |
@@ -78,7 +79,7 @@ import { ProfileScreen } from "@hjmds/react-native/screen-flows";
78
79
  | --- | --- | --- |
79
80
  | 크기 | ScreenLayout 폭(최대 720); 수정은 ghost `Button` 기본 크기 | `ProfileScreen` |
80
81
  | 간격 | 화면 padding `spacing.md` 16; summary–수정 `spacing.md` 16; (summary·수정 묶음)–`children`–`accountActions` `spacing.xl` 24 | Web·Native `ProfileScreen` `Stack gap="xl"`·`gap="md"` |
81
- | 순서·정렬 | 헤더(제목) → summary → 수정 → `children` → `accountActions` | 렌더 순서 |
82
+ | 순서·정렬 | 헤더(제목) → summary → 수정 → `children` → `accountActions`. `editPlacement="summary"`면 수정이 summary 안(ProfileSummary 수치 아래 행동 줄 맨 앞)으로 들어가고 summary 바로 다음이 `children`(`spacing.xl` 24) | 렌더 순서 |
82
83
  | 고정·스크롤 | 헤더 고정, 본문 전체 화면 스크롤(footer 없음); 계정 행동은 본문 끝 | `ScreenLayout` |
83
84
  | 좁은 폭·큰 글자 | 요약 줄바꿈·아바타 크기는 `summary`(제품) 소유; 제목 열 최소 폭 120 × 글자 배율 | `screenPatternRecipe.headerMinWidth` |
84
85
 
@@ -87,3 +88,8 @@ import { ProfileScreen } from "@hjmds/react-native/screen-flows";
87
88
  - 계정 정보 조회, 인증, 로그아웃·탈퇴 mutation과 그 확인([AlertDialog](alert-dialog.md))은 제품 소유다.
88
89
  - 프로필 이미지·닉네임 문구 등 표시 데이터는 제품이 넘긴다. HJM은 계정 모델을 모른다.
89
90
  - 요약 자리에 브랜드 색을 하드코딩하지 않고 HJM 토큰·제품 테마로 연결한다.
91
+ - 수정 행동을 프로필 카드 안에 두려고 ProfileScreen 대신 ScreenLayout + ProfileSummary `actions`로 다시 조립하지 않는다. `editPlacement="summary"`를 쓴다. 비로그인 계정 화면처럼 같은 자리에 "로그인"을 두는 경우도 `edit`의 `label`·`onAction`을 바꿔 연결한다.
92
+
93
+ ## 근거
94
+
95
+ - `editPlacement`(1.31.0): 2026-10-15 사용자 결정으로 utilverse 계정 화면은 프로필 수정(비로그인 때 로그인) 행동을 프로필 카드 안에 둔다. ProfileScreen은 수정을 요약 밖 ghost 버튼으로 고정해서, 2026-10-18 채택 때 utilverse가 ProfileScreen을 쓰지 못하고 ScreenLayout과 ProfileSummary `actions`로 같은 틀을 다시 조립했다.
@@ -4,7 +4,7 @@
4
4
  - 상태: 실험
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.26.0
7
- - 검토일: 2026-10-16
7
+ - 검토일: 2026-10-18
8
8
  - 근거: [프로필 화면](../screens/common-profile.md), `src/screen-patterns.ts`의 `profileSummaryRecipe`, 양 renderer `src/screen-flows.tsx`
9
9
  - 스토리북: 별도 예제 미등록
10
10
 
@@ -50,7 +50,7 @@ import { ProfileSummary } from '@hjmds/react-native/screen-flows';
50
50
  | badge | ReactNode | 없음 | 이름 옆 배지 |
51
51
  | nameActions | ReactNode | 없음 | 이름 행의 행동 |
52
52
  | stats | `{id,value,label,onPress?,disabled?}[]` | `[]` | 통계 수치. `onPress`가 있으면 버튼 |
53
- | actions | ReactNode | 없음 | 수치 아래 행동 줄 |
53
+ | actions | ReactNode | 없음 | 수치 아래 행동 줄. [ProfileScreen](profile-screen.md) `editPlacement="summary"` 안에서는 그 화면의 `edit`이 이 줄 맨 앞에 `secondary` `small` 버튼(`profileSummaryRecipe.editAction`)으로 먼저 들어간다(1.31.0) |
54
54
  | children | ReactNode | 없음 | 하단 footer |
55
55
 
56
56
  `stats`는 각 수치의 독립 행동을 보존하며 조회·팔로우·인증·라우팅은 앱에서 연결한다. 팔로우 상태 기계까지 필요한 경우 기존 FollowProfileCard 사용 여부를 먼저 검토한다.
@@ -59,7 +59,7 @@ import { ProfileSummary } from '@hjmds/react-native/screen-flows';
59
59
 
60
60
  | 항목 | 값 | 근거 |
61
61
  | --- | --- | --- |
62
- | 크기 | 원형 아바타 프레임 72 | `profileSummaryRecipe.avatarSize` |
62
+ | 크기 | 원형 아바타 프레임 72. ProfileScreen 수정 행동은 `Button` `secondary` `small` | `profileSummaryRecipe.avatarSize`·`editAction` |
63
63
  | 간격 | 수치 칸 사이 `spacing.md` 16, 값·라벨 사이 `spacing.xxs` 4, 행동 사이 `spacing.sm` 12. 영역 간격은 [CompositionLayout](composition-layout.md) | 양 renderer |
64
64
  | 순서·정렬 | 머리(아바타 → 이름·배지 → nameActions) → 수치 줄 → actions → children, 수치 칸은 균등 폭·가운데 | `CompositionLayout` + `flex: 1` 칸 |
65
65
  | 고정·스크롤 | 없음. ProfileScreen 본문 스크롤 안에 놓인다 | — |
@@ -71,6 +71,7 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
71
71
  | `keyboardAvoidance` (Native) | `boolean` | `true`(미게시) | 키보드와 실제로 겹치는 만큼 footer·본문을 올리고 포커스된 입력칸을 스크롤해 보인다. `KeyboardAvoiding`·키보드 처리 Sheet/Dialog 안에서는 건너뛴다 |
72
72
  | `title` | `string` | 필수 | 화면 제목. `header`를 주면 접근성 이름으로만 쓴다 |
73
73
  | `titleBadge` | `ReactNode` | 없음 | 기본 제목 바로 옆 배지. 값·의미는 제품, 간격·정렬은 HJM. `header` 교체 또는 제목 숨김이면 그리지 않는다. 1.23.0 추가 API |
74
+ | `titleLeading` | `ReactNode` | 없음 | 미게시(1.30.0 이후). 제목 앞 식별 표시(팀 로고·도구 아트워크). 장식이라 보조기술에서 숨기고 제목이 heading·접근성 이름으로 남는다. `titleVisibility="hidden"`이면 표시가 제목 자리에 혼자 서고 `title`을 이름으로 하는 heading(level 1)이 된다. 이때도 아이콘 전용 48 띠가 아니라 제목 띠 52를 쓴다. 노드 크기는 제품이 주되 헤더 띠 안에 들어가야 한다. `header` 교체 시 그리지 않는다. `ChatScreen`은 같은 자리를 `avatar`로 받는다 |
74
75
  | `header` | `ReactNode` | 없음 | 기존 navigator 헤더 유지용. 주면 기본 헤더(`exit`·제목·`description`·`actions`)를 그리지 않는다 |
75
76
  | `description` | `string` | 없음 | 제목 아래 muted 설명 |
76
77
  | `exit` | `{ kind: "back" \| "close"; label; onClick(Web) \| onPress(Native); disabled? }` | 없음 | 화면 나가기(뒤로·닫기·"설정 나가기")를 헤더 시작 쪽 아이콘 버튼으로 고정한다. 글자는 보이지 않고 `label`은 보조기술 이름이다. `back`은 ‹(RTL 반전), `close`는 ×. 헤더 시작 쪽에는 이 버튼 외의 자유 슬롯이 없다. 미게시(1.26.1 이후) |
@@ -79,10 +80,11 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
79
80
  | `footer` | `ReactNode` | 없음 | 하단 고정 행동. footer가 위 경계·여백·safe area를 맡고, 안에 둔 [BottomCTA](bottom-cta.md)는 자기 바를 그리지 않고 설명·행동 줄만 그린다(2026-10-17, 경계 두 겹·버튼 이중 들여쓰기 수정) |
80
81
  | `state` | `{ kind: "ready" }` · `{ kind: "loading" \| "empty" \| "error" \| "restricted"; title; description? }` | `{ kind: "ready" }` | ready가 아니면 children을 그리지 않고 상태와 `stateAction`을 가운데에 놓는다. 로딩은 Spinner 하나만 보이고 `title`·`description`은 Spinner의 접근성 이름이 된다 |
81
82
  | `stateAction` | `ReactNode` | 없음 | 상태 안내 아래 행동(재시도 등) |
83
+ | `loadingIndicator` | `ReactNode` | 없음 | 미게시(1.30.0 이후). `state.kind === "loading"`일 때 Spinner 대신 그리는 브랜드 표시. 표시 자체는 보조기술에서 숨기고, `title`·`description`을 이름으로 하는 busy 상태(Web `role="status"`, Native `progressbar`)·가운데 배치·알림은 HJM이 맡는다. [CompositionLayout](composition-layout.md)의 같은 prop과 같은 계약 |
82
84
  | `scroll` | `"screen"` · `"content"` | `"screen"` | `content`는 본문 자식(가상화 목록)이 스크롤을 소유한다. 상태 교체 중에는 `screen`으로 돌아간다 |
83
85
  | `contentAlignment` | `"start"` · `"center"` | `"start"` | ready 본문을 사용 영역 중앙에 배치. 긴 본문은 위가 잘리지 않게 스크롤한다. 로그인/제품 안내를 가짜 query state로 바꾸지 않는다(1.24.0) |
84
86
  | `surface` | `"default"` · `"transparent"` | `"default"` | 투명은 제품 host의 배경·wallpaper를 보존. 배경색 style 우회 대신 사용(1.24.0) |
85
- | `contentInset` | `"default"` · `"none"` · `"body-edge"` | `"default"` | `none`은 헤더·notice·본문·footer padding을 0으로(Native는 footer 위 테두리도 뺀다). host가 모든 여백을 이미 줄 때 쓴다. `body-edge`는 헤더·notice·footer 여백(16)과 footer 테두리를 유지하고 **본문 좌우 padding만 0**으로 한다. 자기 좌우 여백을 가진 전체 폭 목록 행(ConversationRow·NotificationRow 등 행 padding 16)을 본문에 바로 둘 때 쓴다(미게시). ListRow·NotificationItem은 `default`에 둔다. 값 목록은 계약 `ScreenContentInset`·`screenContentInsets` |
87
+ | `contentInset` | `"default"` · `"none"` · `"body-edge"` | `"default"` | `none`은 헤더·notice·본문·footer padding을 0으로(Native는 footer 위 테두리도 뺀다). host가 모든 여백을 이미 줄 때 쓴다. `body-edge`는 헤더·notice·footer 여백(16)과 footer 테두리를 유지하고 **본문 좌우 padding만 0**으로 한다. 자기 좌우 여백을 가진 전체 폭 목록 행(ConversationRow·NotificationRow 등 행 padding 16)을 본문에 바로 둘 때 쓴다(미게시). ListRow·NotificationItem은 `default`에 둔다. `body-bleed`(미게시, 1.31.0 이후)는 같은 여백을 유지하고 **본문 네 방향 padding을 모두 0**으로 해 지도·캔버스가 헤더(또는 notice)·화면 좌우·footer에 바로 닿는다. footer가 없으면 본문이 마지막 영역이라 여백 16 없이 `safeAreaBottom`(Native)과 겹친 하단 탐색 높이만 비운다. 홈 인디케이터 아래까지 그리려면 `safeAreaBottom`을 넘기지 않는다. `body-edge`·`body-bleed`에서도 로딩·빈 상태·오류 안내 묶음은 자기 여백이 없으므로 좌우 16을 유지한다(1.32.0 수정). 값 목록은 계약 `ScreenContentInset`·`screenContentInsets` |
86
88
  | `presentation` | `"dashboard"` · `"editorial"` · `"landscape"` | 가장 가까운 profile.screens.overview / 없으면 기존 골격 | [디자인 프로필](../../design-profile.md)의 제공 범위를 따른다. 명시값이 프로필을 이긴다. 헤더 배치·읽기 폭만 바꾸고 상태·입력·행동 슬롯은 유지한다 |
87
89
  | `as` (Web) | `"main"` · `"section"` | `"main"` | 제품 셸에 이미 `<main>`이 있으면 `section` |
88
90
  | `initialFocus` | `first-input` · `none` | `none` | 1.28.1. 진입 시 텍스트 입력 하나에 한 번 포커스한다. 아래 [진입 포커스](#진입-포커스) |
@@ -96,10 +98,10 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
96
98
  | 항목 | 값 | 근거 |
97
99
  | --- | --- | --- |
98
100
  | 크기 | 폭 100%, 최대 `screenPatternRecipe.maxWidth`(`layout.readingMaxWidth` 720), 가운데 정렬; 높이는 host가 준 남은 높이(Web `block-size: 100%`, Native `flex: 1`) | Web `.hjm-screen`, Native `ScreenLayout` |
99
- | 간격 | 바깥 padding `screenPatternRecipe.padding`(`spacing.md` 16)을 헤더 사방·notice 좌우·본문 사방·footer 사방에 준다(`contentInset="none"`이면 0, `"body-edge"`면 본문 좌우만 0); 헤더 안 `exit`·제목·`actions` 간격 `itemGap`(`spacing.sm`) 12(두 플랫폼, `contentInset="none"`이어도 유지); 상태 안내는 위아래 `sectionGap`(`spacing.xl`) 24, 안내–`stateAction` `stateGap`(`spacing.md`) 16 | Web `src/screens.tsx`(`--hjm-screen-item-gap`·`--hjm-screen-state-gap`)·`.hjm-screen__*`, Native `ScreenLayout` |
101
+ | 간격 | 바깥 padding `screenPatternRecipe.padding`(`spacing.md` 16)을 헤더 사방·notice 좌우·본문 사방·footer 사방에 준다(`contentInset="none"`이면 0, `"body-edge"`면 본문 좌우만 0, `"body-bleed"`면 본문 사방 0); 헤더 안 `exit`·제목·`actions` 간격 `itemGap`(`spacing.sm`) 12(두 플랫폼, `contentInset="none"`이어도 유지); 상태 안내는 위아래 `sectionGap`(`spacing.xl`) 24, 안내–`stateAction` `stateGap`(`spacing.md`) 16 | Web `src/screens.tsx`(`--hjm-screen-item-gap`·`--hjm-screen-state-gap`)·`.hjm-screen__*`, Native `ScreenLayout` |
100
102
  | 제목 | 기본 가운데 `Heading` `screenHeaderRecipe.centeredTitleLevel`(level5), `headerAlignment="start"`면 `titleLevel`(level3). 추가 기능 버튼(`actions`)은 끝(오른쪽) 위, 나가기(`exit`)는 시작(왼쪽) 위. 헤더 높이는 두 플랫폼 모두 `screenHeaderRecipe`의 고정 띠 | `screenHeaderRecipe` |
101
103
  | 순서·정렬 | 헤더(`exit` → 제목·설명 → `actions`) → notice → 본문 → footer; 상태 안내는 본문 세로 가운데 `exit`는 HJM이 `edge="start"`로 그려 아이콘 칸이 헤더 여백 선 16에 온다(Web·Native 31 → 16, 가운데 헤더의 같은 폭 양쪽 열·제목 중앙은 그대로). 끝 `actions`의 마지막 ghost IconButton은 제품이 `edge="end"`를 준다(2026-10-17 실측 감사, [IconButton `edge`](icon-button.md#배치)) | 렌더 순서 |
102
- | 고정·스크롤 | 헤더·notice·footer 고정, `scroll="screen"`은 본문이 스크롤, `"content"`는 자식이 스크롤; footer 위 테두리 1. 마지막 영역(footer, 없으면 본문)의 아래 여백은 `max(16, 하단 inset)`이다 — Web은 `env(safe-area-inset-bottom)`, Native는 `safeAreaBottom`. 16 + inset으로 더하지 않으며, 키보드가 열려 있으면 키보드가 홈 인디케이터를 덮으므로 16만 둔다 | Web `data-scroll`·`.hjm-screen__footer`, Native `ScrollView` |
104
+ | 고정·스크롤 | 헤더·notice·footer 고정, `scroll="screen"`은 본문이 스크롤, `"content"`는 자식이 스크롤; footer 위 테두리 1. 마지막 영역(footer, 없으면 본문)의 아래 여백은 `max(16, 하단 inset)`이다 — Web은 `env(safe-area-inset-bottom)`, Native는 `safeAreaBottom`. 16 + inset으로 더하지 않으며, 키보드가 열려 있으면 키보드가 홈 인디케이터를 덮으므로 16만 둔다. [BottomNavigationInsetProvider](bottom-navigation.md#막대가-덮는-높이-bottomnavigationinsetprovider-1310) 안이면 같은 마지막 영역이 `max(위 값, 16 + 덮인 높이)`를 한 번 둔다. `scroll="content"` 목록이나 ScreenLayout 위의 화면 구성이 다시 더하지 않는다(1.32.0) | Web `data-scroll`·`.hjm-screen__footer`, Native `ScrollView` |
103
105
  | 좁은 폭·큰 글자 | 제목 열 최소 폭 `headerMinWidth` 120 × 글자 배율, 모자라면 `actions`가 다음 줄로 내려간다; 제목은 줄바꿈(`overflow-wrap: anywhere`)되고 자르지 않는다; Native 위·옆 safe area·탭바 inset은 host, 아래 inset은 `safeAreaBottom` | `screenPatternRecipe.headerMinWidth`, Web `.hjm-screen__heading` |
104
106
 
105
107
  ## 진입 포커스
@@ -119,6 +121,7 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
119
121
  - 시점: Native는 화면·시트 진입 전환이 끝난 뒤(`InteractionManager`, 시트는 표시 뒤) — 키보드 애니메이션이 전환과 겹치지 않는다. Web은 mount 직후, `preventScroll`.
120
122
  - 빼앗지 않는다: 사용자가 이미 입력 칸에 있으면 그대로 둔다(Native 포커스된 입력, Web 편집 가능한 요소).
121
123
  - 오버레이 아래(1.28.2, Web·Native): 위에 HJM `Dialog`·`AlertDialog`·`Sheet`가 열려 있으면 아래 화면·시트는 고르지 않는다. Web은 제품 자체 모달(`aria-modal`·`dialog[open]`)도 막는다. Native는 HJM 오버레이만 알 수 있으므로 화면을 덮는 모달은 HJM 오버레이로 만든다.
124
+ - 오버레이 안의 화면(1.31.0, Web·Native): 화면을 **감싸는** 모달은 그 화면을 막지 않는다. `Dialog`·`Sheet` 본문에 둔 `ScreenLayout`(또는 화면 구성)은 오버레이가 열린 뒤(Web: 모달 활성화, Native: 표시·진입 애니메이션 뒤) 오버레이와 함께 진입해 자기 `initialFocus` 규칙으로 칸을 고른다. 오버레이 자신의 칸이 있으면 그 칸이 먼저이고, 오버레이에 `initialFocusRef`(Web)가 있으면 그쪽이 이긴다. Web 제품 모달(`aria-modal`)도 화면의 입력 칸을 품고 있으면 화면을 감싸는 것으로 보고 막지 않는다. 화면 밖에 열린 제품 모달은 그대로 막는다.
122
125
  - 진입 창(1.28.2): 진입 뒤 아직 포커스하지 못한 범위는 사용자가 그 범위를 직접 조작(Native 터치·스크롤, Web 포인터·키·휠·터치)하기 전까지 열려 있다. 그 동안 ① 칸이 새로 나타나면(데이터 로드 뒤 드러나는 확인 칸) ② 진입 때 위에 있던 오버레이가 닫히면(첫 가입 약관 동의) 다시 고른다. 오버레이 안의 조작(동의 탭)은 아래 화면의 조작으로 세지 않는다. 오버레이가 닫힌 시점은 그 오버레이의 반환 포커스와 같은 시점(Native: 퇴장·dismiss 뒤, Web: 반환 포커스 뒤)이다.
123
126
  - 나중에 드러나는 주 입력(1.28.2): 진입이 끝난 뒤 `initialFocusTarget`(`true`) 칸이 새로 mount되면 그 칸을 포커스한다. `initialFocus` 모드와 무관하다 — 검색 버튼으로 여는 검색 칸(`none` 화면), 단계형 시트·`EditorScreen`의 단계 칸(각 단계 칸에 `initialFocusTarget`)이 이 경우다. 다른 입력 칸에 초점이 있거나 위에 오버레이가 있으면 하지 않는다. 스스로 다시 mount되는 칸(가상화 목록 행)에는 주지 않는다.
124
127
  - 다시 진입: 화면은 mount당 한 번이다. 스택에서 돌아온 화면(이미 mount)은 다시 포커스하지 않는다 — 돌아온 맥락을 키보드가 가리고, HJM은 내비게이션 포커스 신호를 모른다. 시트·대화상자는 열 때마다 다시 고른다.
@@ -143,6 +146,8 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
143
146
  - Web host는 실제 남은 높이(`height:100%` 등)를, Native host는 위·옆 safe area와 탭바 inset을 먼저 처리한다. 아래 홈 인디케이터는 host가 SafeAreaView로 들이지 말고 `safeAreaBottom`으로 넘긴다(들인 뒤 footer 16을 더하면 버튼 아래가 50이 된다).
144
147
  HJM은 기종별 높이를 추측하지 않는다.
145
148
  - 배치는 Web·Native 모두 `layoutStyle`로 한다(Web은 `className`도 있다). 색·여백을 덮지 않는다.
149
+ - 제목 옆 로고·아트워크 때문에 `header`에 TopBar나 직접 만든 헤더를 넣지 않는다. `titleLeading`을 쓰면 가운데 제목·양쪽 같은 폭 열·`exit`·`actions` 배치가 그대로 유지된다.
150
+ - 첫 로딩에 브랜드 로더를 보이려고 `ready` 본문 안에 로딩 화면을 직접 두지 않는다. `state={{ kind: "loading", title }}` + `loadingIndicator`로 넘긴다.
146
151
 
147
152
  ## 플랫폼 차이
148
153
 
@@ -158,6 +163,7 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
158
163
  - 이미 `<main>`이 있는 제품 셸 안에서는 `as="section"`을 준다. 중첩 main이 생긴다.
159
164
 
160
165
  - 본문 좌우 여백을 직접 빼려고 목록에 음수 margin이나 제품 CSS를 쓰지 않는다. 행이 자기 여백을 가지면 `contentInset="body-edge"`, host가 모든 여백을 가지면 `"none"`이다. ListRow·NotificationItem은 글자를 부모 콘텐츠 가장자리에 두고 표면만 8 바깥으로 넓히므로(2026-10-17) `default` 본문에서 글자가 여백 선 16에 오고 설정 묶음 제목·알림 그룹 제목과 같은 선에 선다. `body-edge` 본문에 두면 글자가 화면 끝에 붙으므로 두지 않는다.
166
+ - 지도·캔버스를 화면 끝까지 그리려고 `body-edge` 본문에 음수 margin을 주거나 `none` + 직접 만든 헤더 여백을 쓰지 않는다. `contentInset="body-bleed"`다. 본문에 글자 목록을 두는 화면에는 쓰지 않는다(글자가 화면 끝에 붙는다). 지도 위에 띄우는 입력판·버튼의 여백은 제품 오버레이가 정한다.
161
167
 
162
168
  ### 디자인 프로필의 화면 표현
163
169
 
@@ -168,6 +174,12 @@ dashboard는 가로 헤더/1200 목록 폭, editorial은 세로 헤더/720 읽
168
174
 
169
175
  ## 근거
170
176
 
177
+ - `titleLeading`: 2026-10-18 HJM 1.30.0 채택 감사. 야잘알 팀 상세는 팀 로고를 제목 자리에 두려고 헤더 전체를 직접 만들었고(측정한 양쪽 폭 맞춤까지 복제), 유틸버스 도구 화면은 도구 아트워크 때문에 `header`에 TopBar를 넣었다. ChatScreen 상대 사진은 이미 같은 자리를 비공개로 쓰고 있었으므로 공개 prop으로 열었다.
178
+ - `loadingIndicator`: 2026-10-18 spint 홈. ScreenLayout 로딩 상태가 브랜드 표시를 받지 않아 첫 로딩을 `ready` 본문 안의 제품 상태 화면으로 그렸다. CompositionLayout에는 같은 prop이 있었다.
179
+
171
180
  - 하단 여백 `max(16, inset)`(Native `safeAreaBottom`): 2026-10-17 사용자 보고 "전체적으로 버튼 아래 여백이 많아". Native Storybook의 사진 선택과 업로드 화면에서 버튼 아래가 약 33pt였다. 원인은 둘이었다. 화면 스토리의 route host가 고정 높이 720이라 iPhone 17 Pro 스토리 영역 737보다 17 짧았고(footer 16 + 빈 칸 17), 컴포넌트는 Native footer가 safe area를 다루지 않아 host의 아래 inset 위에 16을, footer 안 BottomCTA의 `safeAreaBottom`도 16 위에 더했다(16 + 34 = 50). Web footer는 이미 `max(16, env())`였지만 footer 안 BottomCTA의 `safeAreaBottom`은 그 위에 더했다. 표준을 "마지막 컨트롤 아래 = footer 위 여백 16, 홈 인디케이터 기기에서는 max(16, inset)"으로 정하고 footer가 한 번만 소유하게 했다.
172
181
  - 화면 여백은 ScreenLayout 소유, 바깥 여백 + `none` 미지원: 2026-10-17 바깥 여백 호스트 예제 사용자 검토. utilverse식(`Container gutter="regular"` 20 + `contentInset="none"`)을 HJM에서 지원하려고 본문 확장·footer 바 소유를 붙인 예제가 어색하다는 판단으로 지원을 접었다(2026-10-15 공통 구조는 HJM 소유 원칙). 측정상 그 구성에서는 Web 402·1280에서 행 표면이 양쪽 8 잘리고 본문이 옆으로 8 스크롤되며, Native 3x에서 선택 테두리 옆 선이 그려지지 않았다. 제품은 바깥 여백을 걷고 기본 inset을 쓴다.
173
182
  - `contentInset="body-edge"`: 2026-10-17 사용자 결정. 화면 검토 S5#9에서 대화 목록 아바타가 화면 끝에서 약 32pt 안쪽에서 시작했다. 본문 padding 16과 ConversationRow 자체 padding 16이 겹쳤다(인스타그램 DM은 약 16). 앱 약 45곳이 "host가 모든 여백을 소유한다"는 뜻으로 `none`을 쓰고 있어 `none`의 의미를 바꿀 수 없었으므로 값을 새로 추가했다. ChatScreen의 10 타임라인 여백은 `default`일 때만 공통 여백을 대신한다.
183
+ - `contentInset="body-bleed"`: 2026-10-18 HJM 1.32.0 감사. 선택의창 지도 화면 세 곳(홈·경로 지도·지도에서 고르기)이 `body-edge`로 지도를 좌우 끝까지 그렸지만 본문 위아래 padding 16이 남아 헤더·footer와 지도 사이에 띠가 생겼다. `body-edge`의 뜻(전체 폭 목록 행의 좌우 여백)과 `none`의 뜻(host가 모든 여백)을 바꿀 수 없어 값을 추가했다.
184
+ - 하단 탐색 여백의 단일 소유: 2026-10-18 HJM 1.32.0. BottomNavigation 사용 지침이 `scroll="content"` 화면에 목록 여백으로 다시 더하라고 안내했으나 ScreenLayout은 그 화면에도 이미 적용했다(두 번 쌓임). 지침을 ScreenLayout 단일 소유로 고쳤다.
185
+ - 오버레이 안의 화면 진입 포커스: 2026-10-18 번뚝 검색 오버레이. Web은 화면을 감싼 제품 `aria-modal`을 화면 위의 모달로 보고 진입 포커스를 막았고, HJM Sheet 안에 둔 ScreenLayout은 Sheet의 첫 컨트롤(닫기)에 포커스를 빼앗겼다. Native는 오버레이 표시 전에 칸을 포커스해 무시될 수 있었다. 감싸는 모달은 막지 않고 오버레이 안의 화면은 오버레이와 함께 진입하도록 고쳤다(1.31.0 patch).
@@ -4,8 +4,8 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.17.1
7
- - 검토일: 2026-10-06
8
- - 근거: [반복 화면 조합](../../screen-patterns.md), Web·Native `src/screens.tsx`·`src/screen-flows.tsx`; 기존 개별 지침을 새 규격으로 통합. `onSubmit`·`filtersOverflow`는 2026-10-06 사용자 요청("검색과 필터 있는 앱 서비스 따라해")의 검색 화면 개편에서 추가. 같은 날 사용자 위임 결정(권장안)으로 미리보기에만 있던 두 단계 검색 로직(`committedQuery`·`recentQueries`·`suggestedQueries`·`suggestions`·`resultSummary`·`appliedFilters`·`filterSheet`)과 `queryLabelVisibility`를, utilverse 적용 조사로 `searching`·`searchingLabel`을 추가([계약 결정 표](../../screen-patterns.md#searchscreen-두-단계-검색)). 1.13.0 utilverse 적용에서 드러난 결함으로 `hostGutter`와 확정 시 키보드 닫기(Web 결과 영역 포커스)를 추가(1.13.1 patch). 예제 스토리는 2026-10-06 사용자 승인으로 스토리북 배포([승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정)). 스토리북 배포는 API 게시가 아니다(`적용` 참고)
7
+ - 검토일: 2026-10-18
8
+ - 근거: [반복 화면 조합](../../screen-patterns.md), Web·Native `src/screens.tsx`·`src/screen-flows.tsx`; 기존 개별 지침을 새 규격으로 통합. `onSubmit`·`filtersOverflow`는 2026-10-06 사용자 요청("검색과 필터 있는 앱 서비스 따라해")의 검색 화면 개편에서 추가. 같은 날 사용자 위임 결정(권장안)으로 미리보기에만 있던 두 단계 검색 로직(`committedQuery`·`recentQueries`·`suggestedQueries`·`suggestions`·`resultSummary`·`appliedFilters`·`filterSheet`)과 `queryLabelVisibility`를, utilverse 적용 조사로 `searching`·`searchingLabel`을 추가([계약 결정 표](../../screen-patterns.md#searchscreen-두-단계-검색)). 1.13.0 utilverse 적용에서 드러난 결함으로 `hostGutter`와 확정 시 키보드 닫기(Web 결과 영역 포커스)를 추가(1.13.1 patch). `filtersRole`은 2026-10-18 choose-window 적용 감사에서 추가(검색어 때문인 0건의 복구 수단인 범위 필터가 숨겨져 제품이 `resultSummary`를 쓰지 못했다). 예제 스토리는 2026-10-06 사용자 승인으로 스토리북 배포([승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정)). 스토리북 배포는 API 게시가 아니다(`적용` 참고)
9
9
  - 스토리북: `배포/화면/검색/검색 결과와 필터`
10
10
 
11
11
  ## 언제 쓰나
@@ -145,7 +145,8 @@ import { SearchScreen } from "@hjmds/react-native/screen-flows";
145
145
  | `queryClearLabel` | `string` | `queryField`가 없으면 필수 | 기본 입력의 지우기 버튼 이름. 비어 있으면 `TypeError` |
146
146
  | `queryField` | `ReactNode` | 없음 | 기본 `SearchField` 대신 그릴 입력. 지우기 동작을 직접 제공한다 |
147
147
  | `debounceMs` | `number` | `searchScreenRecipe.debounceMs` (`300`) | 유한값을 받으며 0 미만은 0. `NaN`·±`Infinity`는 `RangeError`. 두 renderer가 `resolveSearchDebounceMs`를 공유한다 |
148
- | `filters` | `ReactNode` | 없음 | 검색 입력 아래, 상단에 고정. 두 단계 검색에서는 결과 단계에만, 검색어 때문인 0건에는 숨긴다 |
148
+ | `filters` | `ReactNode` | 없음 | 검색 입력 아래, 상단에 고정. 두 단계 검색에서는 결과 단계에만. 검색어 때문인 0건에는 `filtersRole="narrow"`(기본)면 숨기고 `scope`면 남긴다 |
149
+ | `filtersRole` | `narrow` · `scope` | `narrow` | 1.32.0, 미게시. 필터 줄(`filters`·`filterSheet.trigger`)이 결과에 하는 일. `narrow`는 결과를 좁히기만 해서 적용한 필터 없이 0건이면 검색어 탓이고 어떤 필터도 결과를 되살리지 못하므로 줄을 숨긴다. `scope`는 무엇을 찾을지 고르는 필터(장소명/주소, 지역, 지도 영역)라 적용한 필터가 없어도 바꾸면 결과가 생길 수 있으므로 0건에도 줄을 남긴다. 판정은 두 renderer가 공유하는 `resolveSearchFilterRailVisible`(`@hjmds/design-contracts/screen-patterns`) |
149
150
  | `onSubmit` | `(query: string) => void` | 없음 | 확정 신호. 모든 확정에서 키보드를 닫는다(Native `Keyboard.dismiss()`, Web은 고른 항목이면 결과 영역으로 포커스, 1.13.1 변경) — 아래 플랫폼 차이. 기본 입력의 Web Enter(`enterKeyHint="search"`, IME 조합 중 Enter는 무시)·Native 키보드 검색 키(`returnKeyType="search"`, `onSubmitEditing`)와 SearchScreen이 그린 확정 행·제안·최근·추천 검색어가 모두 여기로 온다. 앞뒤 공백을 지운 값이고 공백만이면 부르지 않는다. 고른 값이 입력과 다르면 먼저 `onQueryChange`. 대기 중인 `onSearch` debounce는 그대로 둔다. `queryField`를 주면 그 입력의 키는 연결하지 않는다 |
150
151
  | `committedQuery` | `string` | 없음 | 결과를 보여 줄 확정 검색어. 주면 두 단계 검색(`onSubmit` 필수, 타입). 단계: 검색어 공백 = 검색 전, 확정값과 다름 = 입력 중, 같음 = 결과(앞뒤 공백 무시). `children`은 결과 단계에만 그린다 |
151
152
  | `queryLabelVisibility` | `visible` · `hidden` | `visible` | `hidden`이면 보이는 label 없이 `queryLabel`을 접근성 이름(Web `aria-label`, Native `accessibilityLabel`)과 placeholder로 쓴다 |
@@ -188,6 +189,7 @@ import { SearchScreen } from "@hjmds/react-native/screen-flows";
188
189
  `onSearch`는 보내지 않는다. `onSearch`에서 저장하면 "산", "산책"이 따로 쌓인다(2026-10-06 개편 전 공통 검색 예제의 결함).
189
190
  - 두 단계 검색에서 `children`에는 결과 목록만 넣는다. 제안·로딩 행·0건 EmptyState·개수·정렬을 다시 그리면 SearchScreen이 그린 것과 겹친다.
190
191
  - 적용 조건은 `appliedFilters`로 넘긴다. 0건 원인(필터면 칩 줄 유지 + `모두 해제`, 검색어면 칩 줄 숨김 + 추천 검색어)을 이 개수로 판정한다.
192
+ - 검색 종류·지역·지도 영역처럼 무엇을 찾을지 고르는 필터는 `filtersRole="scope"`로 표시한다. 검색어 때문인 0건에도 필터 줄이 남아 바꿔 볼 수 있다. 이 때문에 `resultSummary`를 버리고 0건·로딩을 `children`에 다시 그리지 않는다(2026-10-18 choose-window 장소명/주소 전환).
191
193
  - 시트 `count`와 결과 개수는 같은 계산에서 나와야 한다. 다르면 "N개 결과 보기"와 적용 뒤 개수가 어긋난다.
192
194
  - `contentInset="none"`으로 host가 여백을 주는 화면은 `hostGutter`에 그 여백 이름을 넘긴다. 없으면 `scroll` 줄이 host 여백 안에서 잘린다.
193
195
  - 칩이 여러 개인 필터 줄은 `filtersOverflow="scroll"`로 한 줄에 둔다. 제품이 CSS·ScrollView로 가로 스크롤을 따로 만들지 않는다
@@ -4,14 +4,15 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-06
7
+ - 검토일: 2026-10-18
8
8
  - 근거: [Steps](../../steps.md), [StepPlayer](../../step-player.md), `src/steps.ts`(`stepsRecipe`)
9
9
  - 스토리북: `배포/컴포넌트/탐색/단계 탐색`, `배포/컴포넌트/상태와 알림/단계별 진행 표시`
10
10
 
11
11
  ## 언제 쓰나
12
12
 
13
13
  여러 단계로 된 **선형 흐름에서 지금 어디인지** 보여 줄 때 쓴다. 온보딩(환영 → 구단 → 알림 → 완료),
14
- 가입·주문 처리 단계가 여기에 속한다. 읽기 전용이며 단계를 눌러 이동하지 않는다.
14
+ 가입·주문 처리 단계가 여기에 속한다. 기본은 표시 전용이고, `onStepPress`를 주면 **이미 지난 단계만** 눌러 그 단계로
15
+ 돌아갈 수 있다(1.31.0, 단계형 작성).
15
16
 
16
17
  ## 쓰지 않을 때
17
18
 
@@ -74,28 +75,30 @@ import { Steps } from "@hjmds/react-native/steps";
74
75
  | `statusLabels` | `{ pending, current, complete, error }`(모두 `string`) | — (필수) | 상태를 읽는 문구 |
75
76
  | `composeAccessibleName` | `(info: { position: number, total: number, label: string }) => string` | — (필수) | `position`은 1부터 |
76
77
  | `renderMark` | `(status: StepStatus, position: number) => ReactNode` | `✓`·`!`·번호 | 원 안 표시를 바꾼다 |
78
+ | `onStepPress` | `(id) => void` | 없음 | 1.31.0. 커서 앞 단계(`revisitable`, 상태는 늘 `complete`)만 원·라벨 전체가 한 버튼이 되고 누르면 그 `id`를 넘긴다. 현재·다음 단계는 표시 전용으로 남는다(앞으로 건너뛰면 검증을 지나친다). 버튼 이름은 `composeAccessibleName` 결과, 상태 문구는 설명(Web `aria-describedby`·Native `accessibilityHint`). 흐름이 잠긴 동안(제출 중)은 prop을 빼면 모두 표시 전용이 된다 |
77
79
  | `layoutStyle` | `HjmCompositionStyleProp` | — | 루트 배치. Web·Native 모두 |
78
80
  | `style`(Native) | 지원하지 않음 | — | 제거된 속성 — `layoutStyle`. 현재 공개 props에서 제거되었다 |
79
81
  | `StepPlayer` `progress`·`playing` | `number`(0~1)·`boolean` | — (필수) | 호스트 소유 |
80
82
  | `StepPlayer` `onPlayingChange`·`onReplay` | `(playing: boolean) => void`·`() => void` | — (필수) | 재생/일시정지·다시 보기 요청 |
81
83
  | `StepPlayer` `labels` | `{ play, pause, replay, progress }` | — (필수) | — |
82
84
 
83
- 방향·clickable·variant 축은 없다(계약에서 배제). 항상 가로 한 줄이다.
85
+ 방향·variant 축과 앞으로 건너뛰는 이동은 없다(계약에서 배제). 항상 가로 한 줄이다.
84
86
 
85
87
  ## 배치
86
88
 
87
89
  | 항목 | 값 | 근거 |
88
90
  | --- | --- | --- |
89
- | 크기 | 단계마다 같은 폭(`minmax(0, 1fr)`, Native `flex: 1`). 원(indicator) 24(`glyph.md`), 연결선 1(`stroke.default`), 현재·오류 단계의 원 테두리 2(`stroke.strong`) | `stepsRecipe`, `styles.css` `.hjm-steps*`, `react-native/src/steps.tsx` |
91
+ | 크기 | 단계마다 같은 폭(`minmax(0, 1fr)`, Native `flex: 1`). 원(indicator) 24(`glyph.md`), 연결선 1(`stroke.default`), 현재·오류 단계의 원 테두리 2(`stroke.strong`). 누를 수 있는 단계는 칸 전체가 버튼이고 최소 높이 44(`stepsRecipe.press.minTouchTarget`) | `stepsRecipe`, `styles.css` `.hjm-steps*`, `react-native/src/steps.tsx` |
90
92
  | 간격 | 단계 사이·원과 글자 사이 `spacing.xs` 8(`stepsRecipe.gap`) | `stepsRecipe.gap` |
91
- | 순서·정렬 | 흐름 화면의 맨 위(TopBar 아래, 본문 위)에 가로 한 줄. 화면 아래 행동 버튼과 떨어뜨리고 탭처럼 누르게 두지 않는다 | — |
93
+ | 순서·정렬 | 흐름 화면의 맨 위(머리 아래, 본문 위)에 가로 한 줄. 화면 아래 행동 버튼과 떨어뜨리고, 같은 수준 화면 사이 이동(탭)으로 쓰지 않는다 | — |
92
94
  | 고정·스크롤 | — | — |
93
95
  | 좁은 폭·큰 글자 | 라벨·설명은 줄바꿈된다(`overflow-wrap: anywhere`). 좁은 폭에서 라벨이 세 줄 이상이 되면 라벨을 줄이거나 [Progress](progress.md)로 바꾼다. 세로 배치 축은 없다 | `styles.css` `.hjm-steps__label` |
94
96
 
95
97
  ## 꼭 지킬 것
96
98
 
97
99
  - `statusLabels` 네 개와 `composeAccessibleName`은 필수다. "3단계 중 2단계" 같은 어순은 제품이 i18n으로 조립한다.
98
- - 이전 단계로 가는 행동은 Steps 밖의 [Button](button.md)으로 둔다.
100
+ - 바로 앞 단계로 가는 주 행동은 Steps 밖에 둔다(단계형 작성은 [EditorScreen](editor-screen.md) `stepBack` 헤더 뒤로, 온보딩은 "이전" [Button](button.md)). `onStepPress`는 여러 단계 앞으로 한 번에 돌아가는 보조 길이다.
101
+ - 누를 수 있는 단계 표시를 제품이 Pressable·button으로 다시 그리지 않는다. 지난 단계·현재·다음의 구분과 이름은 HJM이 만든다.
99
102
  - `StepPlayer`는 타이머가 없다. `progress`(0~1)·`playing`·커서를 호스트가 소유하고, `onPlayingChange`·`onReplay`에서
100
103
  직접 상태를 바꾼다. `labels`(`play`·`pause`·`replay`·`progress`)는 비면 `TypeError`, `progress`가 범위를 벗어나면 `RangeError`다.
101
104
  - 실제 비동기 작업의 완료를 `StepPlayer` 시계로 추정하지 않는다([계약](../../step-player.md)).
@@ -104,7 +107,11 @@ import { Steps } from "@hjmds/react-native/steps";
104
107
 
105
108
  | 항목 | Web | Native |
106
109
  | --- | --- | --- |
107
- | 구조 | `<ol>`, 현재 단계 `aria-current="step"`, 상태 문구는 숨은 텍스트 | `accessibilityRole="summary"`, 단계마다 이름 + `accessibilityHint`에 상태 문구 |
110
+ | 구조 | `<ol>`, 현재 단계 `aria-current="step"`, 상태 문구는 숨은 텍스트. 누를 수 있는 단계는 `<li>` 안 `<button>`(이름 = 단계 이름, 설명 = 상태 문구) | `accessibilityRole="summary"`, 단계마다 이름 + `accessibilityHint`에 상태 문구. 누를 수 있는 단계는 `Pressable` `button` |
108
111
  | 스타일 | `className`·`style`(HTML 속성), 배치는 `layoutStyle` | 배치는 `layoutStyle`(`style`은 제거된 속성) |
109
112
  | ref | `forwardRef`(`ol`) | 없음 |
110
113
  | import 경로 | root, `/navigation`, `/steps` | root, `/steps`(`/navigation` 없음) |
114
+
115
+ ## 근거
116
+
117
+ - `onStepPress`(1.31.0): 2026-10-18 spint 작성(색 → 이모지 → 글)이 지난 단계를 눌러 돌아가려고 단계 레일 전체를 PressableScale로 직접 그렸다. 계약은 "필요해지면 추가"로 clickable을 미뤄 두었고([계약](../../steps.md#공개한-축--배제한-축)), 앞으로 건너뛰기는 검증을 지나치므로 커서 앞 단계만 연다.
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-07
7
+ - 검토일: 2026-10-18
8
8
  - 근거: [Gooey navigation](../../gooey-navigation.md), `src/component-recipes.ts`(`tabsRecipe`), `src/behaviors.ts`(`tabsBehaviorDefaults`)
9
9
  - 스토리북: `배포/컴포넌트/탐색/탭`, `배포/컴포넌트/탐색/선택 표시가 이어지는 탭`
10
10
 
@@ -77,6 +77,7 @@ import { Tabs } from "@hjmds/react-native/navigation";
77
77
  | `overflow` | `scroll` · `clip` | `scroll` | — |
78
78
  | `orientation` | `horizontal` · `vertical` | `horizontal` | — |
79
79
  | `loop` | `boolean` | `true` | — |
80
+ | `indicator` | `underline` · `filled` | `underline` | 1.31.0 추가. `filled`는 선택 탭 뒤에 브랜드 연한 면(`surface.brand`) 알약을 깔고 글자를 `content.brand`로 바꾸며 목록 구분선을 없앤다. `slide`·`gooey`·`swipePosition`과 함께 쓰면 알약이 움직인다 |
80
81
  | `appearance` | `standard` · `slide` · `gooey` | 미지정 시 프로필의 selectionMotion; 없으면 `standard` | 1.15.0 변경: `slide`는 2점 표시선 이동, `gooey`는 6점 표시선 늘어남. 명시 값 우선, 세로는 standard 유지 |
81
82
  | `renderPanels` | `boolean` | `true` | `false`면 패널을 그리지 않는다. 패널을 라우터·스크롤 상태와 함께 따로 둘 때 `TabPanel`에 `tabsId`(Tabs의 `id`와 같게)·`activeValue`·`value`를 넘긴다 |
82
83
  | `children`(Native) | `(selectedValue: Value) => ReactNode` | — | 항목 `panel` 대신 선택 값으로 패널을 그린다 |
@@ -105,6 +106,19 @@ import { Tabs } from "@hjmds/react-native/navigation";
105
106
  └────────────────────────────────────┘
106
107
  ```
107
108
 
109
+ ### 채운 선택 표시 (`indicator="filled"`)
110
+
111
+ 선택 표시를 2점 밑줄 대신 선택 탭 전체를 채우는 알약으로 그린다. 값은 `tabsRecipe.indicators.filled`다.
112
+ 알약 면 `surface.brand`(surfaceAccent), 선택 글자 `content.brand`, 모서리 `radius.full`, 목록 구분선 없음.
113
+ Native는 `filled`일 때 탭 사이에 `tabsRecipe.gap` 8을 두어 이웃 알약이 붙지 않게 한다(Web 목록은 원래 8 간격).
114
+ 움직이는 표시(`appearance="slide"`·`"gooey"`, `swipePosition`)와 함께 쓰면 알약이 탭 높이 그대로 라벨 뒤에서 이동한다.
115
+ 앱 하단 탭이 현재 위치를 연한 알약으로 표시하는 제품에서 화면 안 탭도 같은 표시를 쓰려고 고른다.
116
+
117
+ ```tsx
118
+ // Native
119
+ <Tabs label={t("sent.group")} layout="fitted" indicator="filled" value={tab} onValueChange={setTab} items={items} />
120
+ ```
121
+
108
122
  ## 꼭 지킬 것
109
123
 
110
124
  - `label`(탭 목록 이름)은 필수이고 비어 있으면 `TypeError`다. 항목 라벨과 함께 i18n 키로 넣는다.
@@ -129,6 +143,8 @@ import { Tabs } from "@hjmds/react-native/navigation";
129
143
 
130
144
  ## 함정
131
145
 
146
+ - `indicator="filled"`는 선택 표시만 바꾼다. 같은 목록의 필터처럼 패널이 하나뿐이면 Tabs가 아니라 [SegmentedControl](segmented-control.md)(`pills`)이다.
147
+
132
148
  - 2026-10-07 [Animated Tabs 대조](../../../../../docs/plans/aceternity-interaction-adoption-2026-10-07.md)에서
133
149
  선택 표시의 이동과 겹친 패널의 이동을 구분했다. 현재 `appearance="gooey"`는 표시선만 늘어나며,
134
150
  패널을 겹쳐 복제하거나 순서를 바꾸지 않는다. 1.15.0 변경 후속 구현에서
@@ -166,3 +182,7 @@ import { Tabs } from "@hjmds/react-native/navigation";
166
182
 
167
183
  source 객체는 렌더마다 바꾸지 않도록 `useMemo` 또는 제품 어댑터의 안정 인스턴스를
168
184
  사용한다. source는 시각적 위치만 전달하므로 접근성·키보드 선택을 대신하지 않는다.
185
+
186
+ ## 근거
187
+
188
+ - 채운 선택 표시: 2026-10-18 다에리 둥지 화면의 보낸 편지·모음 탭. 앱 하단 탭은 현재 위치를 연한 알약으로 보이는데 화면 안 탭만 밑줄이라 다른 제품처럼 보였다.
@@ -35,6 +35,7 @@ Text와 Heading의 경계: Text의 `variant="heading"`(24px)은 **모양만**
35
35
  | --- | --- | --- | --- |
36
36
  | `Text` | 기본 | `@hjmds/react`, `/layout` | `@hjmds/react-native`, `/primitives` |
37
37
  | `StatusText` | 짧은 상태·결과 알림 companion | `@hjmds/react`, `/status-text` | `@hjmds/react-native`, `/status-text` |
38
+ | `useHjmTextFace` | 보조 — Text 밖 제품 글자의 글꼴 해석 hook(컴포넌트 아님, 1.31.0) | — (CSS 상속) | `@hjmds/react-native`, `/primitives` |
38
39
  | `GravityLetters` | 확장(홍보용 글자 낙하 장식, optional-extension) | `/gravity-letters` | `/gravity-letters` |
39
40
 
40
41
  `GravityLetters`는 granular subpath로만 import 된다. 추가 peer 의존성은 없다(Web은 WAAPI, Native는 RN `Animated`).
@@ -63,6 +64,8 @@ import { Text } from "@hjmds/react-native/primitives";
63
64
  | `fontRole` | `ui` · `display` · `reading` · `code` | 제목 variant는 display, 본문은 reading, caption/label은 ui | 명시값이 기본을 덮는다. [역할 지침](../tokens/font-roles.md), 1.16.0 변경 |
64
65
  | `emphasis` | `regular` 400 · `medium` 500 · `strong` 500(제목 굵기) | 프로필이 있으면 variant 굵기, 없으면 `regular`. `label`은 프로필과 무관하게 variant 굵기 500 | 명시값이 프로필과 variant보다 우선 |
65
66
  | `tone` | `primary` · `body` · `muted` · `subtle` · `weak` · `brand` · `danger` · `inverse` | `primary` | — |
67
+ | `color` | `string` | — | 제품이 소유한 도메인 색 역할(팀 색·등락·차트 계열)로 `tone` 색만 바꾼다. variant 크기·굵기·글꼴은 그대로. 1.31.0 추가 |
68
+ | `numeric` | `proportional` · `tabular` | —(글꼴 기본) | `tabular`는 바뀌는 숫자(점수·타이머·개수)의 폭을 고정한다. Web `font-variant-numeric`, Native `fontVariant`. 1.31.0 추가 |
66
69
  | `as` | `span` · `p` · `div` · `strong` · `small` | `span` | Web만. 문단은 `as="p"` |
67
70
  | `align` | `TextStyle["textAlign"]` | 쓰기 방향의 시작 쪽 | Native만 |
68
71
  | `layoutStyle` | `HjmCompositionStyleProp` | — | 배치. Web·Native 모두 |
@@ -86,15 +89,41 @@ Native는 Text의 역할을 해당 stack에, 입력/조작 host는 제품 프로
86
89
  - 글자는 i18n 키로 넣는다. 제품 이름·도메인 문구는 제품 소유, 크기·색 체계는 HJM 소유다.
87
90
  - 배치(여백·폭·flex)는 `layoutStyle`로만 한다. 색·크기·굵기는 `style`/`className`으로 덮지 않는다.
88
91
  (Native Text의 `style`은 layout primitive라 deprecated 경고 대상에서 빠져 있을 뿐 권장 경로가 아니다.)
89
- - 색으로 의미를 바꿀 때는 `tone`만 쓴다(오류 문구 `danger`, 어두운 배경 위 `inverse`).
92
+ - 색으로 의미를 바꿀 때는 `tone`을 쓴다(오류 문구 `danger`, 어두운 배경 위 `inverse`). HJM tone에 없는 제품 도메인 색
93
+ (팀 색, 등락 빨강/파랑)만 `color`로 넘긴다. 이때 배경 대비 확인은 제품 몫이다. `style.color`로 덮지 않는다.
90
94
  - `GravityLetters`는 장식이며 접근성 트리에서 숨는다. 읽을 수 있는 같은 문구를 함께 두되 화면에 두 번 보이지 않게 한다: Web은 같은 Heading 안의 `VisuallyHidden`, Native는 `accessibilityRole="header"`·`accessibilityLabel`을 가진 감싸는 View.
91
95
  `glyphs`는 32개 이하, 빈 문자열·줄바꿈 없이 넘긴다(어기면 `RangeError`). `active` 기본은 `false`다.
92
96
 
97
+ ### 제품 색과 고정 폭 숫자(1.31.0 추가)
98
+
99
+ 2026-10-18 야잘알의 제품 색 기록·연속 기록 문구는 HJM Text에 제품 색을 넣을 공개 경로가 없어 RN `Text`로 내려갔고,
100
+ RN은 글꼴을 상속하지 않아 `fonts="pretendard"`에서도 OS 글꼴로 그려졌다. 같은 이유로 숫자 폭 고정도 직접 `fontVariant`를 썼다.
101
+
102
+ ```tsx
103
+ // Native(Web도 같은 prop)
104
+ <Text variant="bodyLarge" emphasis="medium" color={streakColor}>{streakLabel}</Text>
105
+ <Text variant="caption" emphasis="strong" numeric="tabular" color={onAccentFill} align="center">{pitch.pitchSequence}</Text>
106
+ ```
107
+
108
+ HJM type 단계(최대 24)보다 큰 제품 숫자처럼 Text로 그릴 수 없는 글자는 Native `useHjmTextFace`로 같은 글꼴을 받는다.
109
+ Provider의 글꼴 설정(`fonts="pretendard"`, designProfile `fontFamily`)과 굵기에 맞는 `fontFamily`/`fontWeight`를 돌려준다.
110
+ 크기·줄 높이·색은 제품이 정한다.
111
+
112
+ ```tsx
113
+ import { useHjmTextFace } from "@hjmds/react-native/primitives";
114
+
115
+ const face = useHjmTextFace({ weight: "800" }); // fontRole 기본 ui
116
+ <RNText style={{ ...face, fontSize: 40, lineHeight: 46, fontVariant: ["tabular-nums"], color }}>{value}</RNText>
117
+ ```
118
+
119
+ Web은 Provider가 CSS 상속으로 글꼴을 내려 주므로 같은 hook이 없다. 제품 요소는 Provider 안에서 HJM 글꼴을 이어받는다.
120
+
93
121
  ## 플랫폼 차이
94
122
 
95
123
  | 항목 | Web | Native |
96
124
  | --- | --- | --- |
97
125
  | 요소 선택 | `as` | 없음(항상 RN `Text`) |
126
+ | Text 밖 제품 글자의 글꼴 | CSS 상속 | `useHjmTextFace` |
98
127
  | 정렬 | 없음(배치는 부모) | `align`(생략 시 방향에 맞춘 논리 정렬) |
99
128
  | 글자 크기 조정 | 브라우저 | provider `textScaling`이 `allowFontScaling`을 정함 |
100
129
  | 긴 단어 줄바꿈 | `overflow-wrap: anywhere` | RN 기본 |
@@ -159,6 +159,9 @@ Native의 선택형 `/toast-liquid`도 가장 가까운 프로필의 `radius.xl`
159
159
  - Provider/Region은 앱에 하나만 두고 화면마다 새로 만들지 않는다. 진행 상태를 바꿀 때는 같은 `id`로 다시 `publish`한다.
160
160
  - 문구는 모두 i18n 키로 만든 일반 문자열이다(ReactNode 아님). 아이콘만 있는 닫기 버튼 때문에 `closeLabel`이 필수다.
161
161
  - 색·모양은 tone이 정한다. 카드에 브랜드 색을 덮지 않는다. 문구·id·action은 제품 소유, 카드 모양·큐·타이머는 HJM 소유다.
162
+ - 가장자리 상태색 띠는 없다. tone은 배지(또는 `media`)와 문구가 전달한다. 띠를 숨기는 옵션·CSS·팔레트 덮어쓰기를 두지 않는다 —
163
+ 1.10.0에 띠를 없앴고, 미게시(1.32.0 예정)부터는 Web의 늘 숨겨져 있던 `.hjm-toast__tone-mark` 요소도 그리지 않는다.
164
+ Native에서 `statusAccents`·`danger`를 바탕색으로 바꾸면 띠가 아니라 tone 아이콘에 넘기는 색(`renderToneIcon`의 `color`)이 지워진다.
162
165
  - Native는 `safeAreaInsets`를 제품이 넘긴다(기본 `{}`). 하단 고정 바가 있으면 Web `bottomOffset`, Native `keyboardOffset`으로 띄운다.
163
166
 
164
167
  ## 플랫폼 차이
@@ -174,6 +177,8 @@ Native의 선택형 `/toast-liquid`도 가장 가까운 프로필의 `radius.xl`
174
177
 
175
178
  ## 함정
176
179
 
180
+ - 2026-10-18 번뚝은 1.10.0에 이미 없어진 띠를 숨기려고 Web `.hjm-toast__tone-mark { display: none }`과 Native 토스트 영역의 팔레트 덮어쓰기를 유지하고 있었다. 띠 계약은 "없음"이며 `toastRecipe.toneMark`는 호환용으로만 남은 deprecated 값(너비 0)이다.
181
+
177
182
  - `/toast-liquid`는 앱에 없을 수 있는 optional native peer `@shopify/react-native-skia`·`react-native-reanimated`·
178
183
  `react-native-worklets`를 import한다(peerDependenciesMeta에서 모두 optional). 2026-10에 이런 subpath가
179
184
  tsc·테스트는 통과했는데 기기 Metro에서 크래시가 났다. 세 peer가 개발 클라이언트 바이너리에 들어 있을 때만 import한다.
@@ -0,0 +1,89 @@
1
+ # TypingIndicator
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 실험
5
+ - 지원: Web · Native
6
+ - 적용: 미게시(1.30.0 이후)
7
+ - 검토일: 2026-10-18
8
+ - 근거: [반복 화면 조합](../../screen-patterns.md), `src/screen-patterns.ts`의 `typingIndicatorRecipe`·`chatMessageRecipe`; Web·Native `src/screens.tsx`
9
+ - 스토리북: `실험/컴포넌트/상태와 알림/입력 중 표시`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 대화 타임라인에서 상대가 지금 입력 중임을 보여 줄 때 쓴다. 수신 말풍선 자리에 점 세 개가 차례로 깜박이는 말풍선을 그리고,
14
+ 보조기술에는 제품이 준 문구("서연님이 입력 중")를 한 번 알린다. 입력 중 여부(presence) 판단과 표시 시점은 제품이 정한다.
15
+
16
+ ## 쓰지 않을 때
17
+
18
+ | 상황 | 대신 쓸 것 |
19
+ | --- | --- |
20
+ | 내 메시지 전송 중 | [ChatMessage](chat-message.md) `deliveryLabel` |
21
+ | 화면·영역 로딩 | [Spinner](spinner.md), ScreenLayout `state` |
22
+ | AI 답변 생성 중 | [StreamingAnswer](streaming-answer.md)·[ThinkingOrb](thinking-orb.md) |
23
+
24
+ ## 공개 이름과 import
25
+
26
+ | 이름 | 역할 | Web | Native |
27
+ | --- | --- | --- | --- |
28
+ | `TypingIndicator` | 기본 | `@hjmds/react/screens` | `@hjmds/react-native/screens` |
29
+
30
+ 루트 barrel에는 없다.
31
+
32
+ ## 최소 사용 예
33
+
34
+ ```tsx
35
+ // Web
36
+ import { ChatMessage, ChatScreen, TypingIndicator } from "@hjmds/react/screens";
37
+
38
+ <ChatScreen title={peer.name} composer={composer}>
39
+ <div>
40
+ {messages.map(m => <ChatMessage key={m.id} {...toMessageProps(m)}>{m.text}</ChatMessage>)}
41
+ {peerTyping ? <TypingIndicator label={t("chat.typing", { name: peer.name })} /> : null}
42
+ </div>
43
+ </ChatScreen>
44
+ ```
45
+
46
+ ```tsx
47
+ // Native — 가상화 목록의 ListFooterComponent로 둔다
48
+ import { FlatList } from "react-native";
49
+ import { TypingIndicator } from "@hjmds/react-native/screens";
50
+
51
+ <FlatList data={messages} renderItem={renderMessage}
52
+ ListFooterComponent={peerTyping ? <TypingIndicator label={t("chat.typing", { name: peer.name })} /> : null} />
53
+ ```
54
+
55
+ ## 축과 기본값
56
+
57
+ | prop | 값 | 기본값 | 설명 |
58
+ | --- | --- | --- | --- |
59
+ | `label` | `string` | 필수 | 지역화한 상태 문구. 화면에는 보이지 않고 보조기술 이름·알림이 된다. 빈 문자열은 `TypeError` |
60
+ | `avatar` | `ReactNode` | 없음 | 단톡방에서 입력 중인 사람 사진. ChatMessage 아바타와 같은 32(`chatMessageRecipe.avatarSize`)로 넘긴다 |
61
+ | `layoutStyle` | `HjmCompositionStyleProp` | 없음 | 행 배치 전용. 색·여백을 덮지 않는다 |
62
+
63
+ ## 배치
64
+
65
+ | 항목 | 값 | 근거 |
66
+ | --- | --- | --- |
67
+ | 크기 | 점 `dotSize` 6(원), 말풍선 최소 높이 = 본문 줄 높이 + 위아래 `bubblePaddingBlock` 6 — 한 줄 수신 말풍선과 같은 높이; radius `bubbleRadius` 14, 표면 `surface` | `typingIndicatorRecipe`, `chatMessageRecipe` |
68
+ | 간격 | 점 사이 `dotGap`(`spacing.xxs`) 4, 말풍선 안 좌우 10; 위 `groupGap` 12(새 묶음처럼 떨어진다); 아바타–말풍선 `avatarGap` 8 | `typingIndicatorRecipe`, `chatMessageRecipe` |
69
+ | 순서·정렬 | 수신 쪽(시작 쪽) 정렬, 마지막 메시지 뒤. 아바타가 없으면 꼬리 폭 `tailWidth` 7만큼 들여 수신 말풍선과 가장자리를 맞춘다 | Web·Native `TypingIndicator` |
70
+ | 고정·스크롤 | 자체 고정 없음. 타임라인 목록 안에서 함께 스크롤한다 | — |
71
+ | 좁은 폭·큰 글자 | 점 크기는 고정. 글자 배율과 무관하다 | Web·Native `TypingIndicator` |
72
+
73
+ ## 꼭 지킬 것
74
+
75
+ - 상대가 실제로 입력 중일 때만 렌더하고, 멈추면 제품이 제거한다. 표시 시간을 HJM이 정하지 않는다.
76
+ - 문구는 제품 i18n으로 넘긴다. 점 표시 옆에 같은 문구를 보이는 글자로 다시 쓰지 않는다.
77
+ - 움직임: 한 주기 `cycleMs` 1200 동안 점마다 `restOpacity` 0.35 ↔ 1로 깜박이고 점 사이 시작 간격은 `staggerMs` 160이다. 동작 줄이기(Provider `reducedMotion`, Web은 `prefers-reduced-motion`도)에서는 점이 깜박이지 않고 불투명하게 멈춘다.
78
+ - 알림은 정중(polite)하다. 나타날 때 한 번 읽고 진행 중인 낭독을 끊지 않는다. 타임라인 전체를 live region으로 감싸지 않는다.
79
+
80
+ ## 플랫폼 차이
81
+
82
+ | 항목 | Web | Native |
83
+ | --- | --- | --- |
84
+ | 알림 | `role="status"` + 숨긴 문구 | 접근성 요소 하나(`accessibilityLabel`), Android polite live region, iOS는 나타날 때 `announceForAccessibilityWithOptions(queue)` |
85
+ | 움직임 | CSS keyframes(`hjm-typing-pulse`) | `Animated.loop`(native driver) |
86
+
87
+ ## 근거
88
+
89
+ - 공통 입력 중 표시: 2026-10-18 번뚝 DM. Web·Native가 각자 점(●●●)과 문구 말풍선을 만들었다(Web `role="status"` 문구, Native 별도 컴포넌트와 Animated 깜박임). 공통 타임라인 구조는 HJM 소유라는 2026-10-15 원칙에 따라 공개 조각으로 옮겼다.
@@ -18,12 +18,13 @@ AuthScreenLayout을 사용해 로그인 흐름을 구성한다. 제품이 데이
18
18
 
19
19
  ```text
20
20
  host: 남은 높이·safe area·키보드
21
- └─ 히어로 → 제공자 카드 → 하단 동의
21
+ └─ (머리 띠: [뒤로/닫기] 로그인) → 히어로 → 제공자 카드 → 하단 동의
22
22
  ```
23
23
 
24
24
  | 영역 | 컴포넌트 | 위치 | 크기·간격 |
25
25
  | --- | --- | --- | --- |
26
26
  | 바깥 틀 | AuthScreenLayout | route 본문 | [API 배치 규칙](../components/auth-screen-layout.md#배치), host 남은 높이 |
27
+ | 머리(선택) | AuthScreenLayout `title`·`exit`(1.31.0) | 맨 위 고정, 화면 가장자리까지 | ScreenLayout 가운데 머리 띠(최소 52). 바깥에 TopBar를 따로 얹지 않는다 |
27
28
  | 내용 | 공개 슬롯 | 히어로 → 제공자 카드 → 하단 동의 | 화면 recipe의 sectionGap·itemGap; 슬롯 안은 각 지침 토큰 |
28
29
  | 상태 | pendingLabel 또는 main 안 Notice | 제공자 카드 내부 | 카드 크기를 유지하고 동의 고지를 남긴다 |
29
30
 
@@ -33,6 +34,7 @@ host: 남은 높이·safe area·키보드
33
34
  | --- | --- | --- | --- |
34
35
  | 작업 | AuthScreenLayout 공개 행동 슬롯 | 제공자 버튼에는 이름만; 로그인 중 카드 중앙 스피너 하나 | 같은 표면에 경쟁하는 primary 하나만 |
35
36
  | 복구 | Button·secondary | 오류 근처 | 재시도할 대상과 범위를 표시 |
37
+ | 나가기 | AuthScreenLayout `exit`(아이콘 뒤로/닫기) | 머리 띠 시작 끝 | 0~1. 로그인 진행 중에는 제품이 `disabled` |
36
38
 
37
39
  ## 상태
38
40