@krizaka/ui 3.6.0 → 3.7.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 (189) hide show
  1. package/dist/chat.js +2 -1
  2. package/dist/chat.js.map +1 -1
  3. package/dist/{chunk-F2GKFIYK.js → chunk-CURW36FI.js} +1 -1
  4. package/dist/chunk-JXLDUDTD.js +2 -0
  5. package/dist/chunk-JXLDUDTD.js.map +1 -0
  6. package/dist/chunk-NPF3OHE2.js +2 -0
  7. package/dist/chunk-NPF3OHE2.js.map +1 -0
  8. package/dist/{chunk-ECSCA3CN.js → chunk-UOMP6VOZ.js} +1 -1
  9. package/dist/data-table.js +2 -1
  10. package/dist/data-table.js.map +1 -1
  11. package/dist/match-dialog.js +4 -2
  12. package/dist/match-dialog.js.map +1 -1
  13. package/dist/native/vertical-video.d.ts +102 -0
  14. package/dist/native/vertical-video.js +163 -0
  15. package/dist/native/vertical-video.js.map +1 -0
  16. package/dist/vertical-video.d.ts +112 -0
  17. package/dist/vertical-video.js +307 -0
  18. package/dist/vertical-video.js.map +1 -0
  19. package/package.json +39 -5
  20. package/registry/achievement.json +1 -1
  21. package/registry/action-bar.json +1 -1
  22. package/registry/activity-feed.json +1 -1
  23. package/registry/alert.json +1 -1
  24. package/registry/app-shell.json +1 -1
  25. package/registry/argument-card.json +1 -1
  26. package/registry/audio-player.json +1 -1
  27. package/registry/auth-layout.json +1 -1
  28. package/registry/avatar.json +6 -6
  29. package/registry/badge.json +1 -1
  30. package/registry/button.json +1 -1
  31. package/registry/card-grid.json +1 -1
  32. package/registry/card.json +1 -1
  33. package/registry/catalogue-page.json +1 -1
  34. package/registry/changelog-page.json +1 -1
  35. package/registry/chat.json +1 -1
  36. package/registry/checkbox.json +1 -1
  37. package/registry/chip-link.json +1 -1
  38. package/registry/chip.json +1 -1
  39. package/registry/choice-grid.json +1 -1
  40. package/registry/code-block.json +1 -1
  41. package/registry/combobox.json +1 -1
  42. package/registry/command.json +1 -1
  43. package/registry/component-page.json +1 -1
  44. package/registry/confirm-button.json +1 -1
  45. package/registry/contact-page.json +1 -1
  46. package/registry/continue-prompt.json +1 -1
  47. package/registry/countdown.json +1 -1
  48. package/registry/cta-panel.json +1 -1
  49. package/registry/dashboard-layout.json +1 -1
  50. package/registry/data-table.json +1 -1
  51. package/registry/date-picker.json +1 -1
  52. package/registry/decision-list.json +1 -1
  53. package/registry/description-list.json +1 -1
  54. package/registry/detail-layout.json +1 -1
  55. package/registry/dialog.json +1 -1
  56. package/registry/docs-article-page.json +1 -1
  57. package/registry/docs-hub-page.json +1 -1
  58. package/registry/docs-layout.json +1 -1
  59. package/registry/dropdown-menu.json +1 -1
  60. package/registry/empty-state.json +1 -1
  61. package/registry/error-page.json +1 -1
  62. package/registry/examples/avatar/group.js +4 -7
  63. package/registry/examples/avatar/group.tsx +4 -11
  64. package/registry/examples/avatar/native/group.dark.png +0 -0
  65. package/registry/examples/avatar/native/group.js +4 -4
  66. package/registry/examples/avatar/native/group.light.png +0 -0
  67. package/registry/examples/avatar/native/group.tsx +4 -4
  68. package/registry/examples/avatar/native/presence.dark.png +0 -0
  69. package/registry/examples/avatar/native/presence.js +8 -5
  70. package/registry/examples/avatar/native/presence.light.png +0 -0
  71. package/registry/examples/avatar/native/presence.tsx +9 -5
  72. package/registry/examples/avatar/with-image.js +1 -4
  73. package/registry/examples/avatar/with-image.tsx +2 -9
  74. package/registry/examples/profile-card/creator.js +2 -2
  75. package/registry/examples/profile-card/creator.tsx +2 -2
  76. package/registry/examples/profile-card/match.js +2 -2
  77. package/registry/examples/profile-card/match.tsx +2 -2
  78. package/registry/examples/profile-card/native/default.dark.png +0 -0
  79. package/registry/examples/profile-card/native/default.js +2 -2
  80. package/registry/examples/profile-card/native/default.light.png +0 -0
  81. package/registry/examples/profile-card/native/default.tsx +2 -2
  82. package/registry/examples/screen/native/app-shell.dark.png +0 -0
  83. package/registry/examples/screen/native/app-shell.js +1 -1
  84. package/registry/examples/screen/native/app-shell.light.png +0 -0
  85. package/registry/examples/screen/native/app-shell.tsx +1 -1
  86. package/registry/examples/screen/native/feed.dark.png +0 -0
  87. package/registry/examples/screen/native/feed.js +31 -14
  88. package/registry/examples/screen/native/feed.light.png +0 -0
  89. package/registry/examples/screen/native/feed.tsx +28 -24
  90. package/registry/examples/screen/native/profile.dark.png +0 -0
  91. package/registry/examples/screen/native/profile.js +2 -2
  92. package/registry/examples/screen/native/profile.light.png +0 -0
  93. package/registry/examples/screen/native/profile.tsx +2 -2
  94. package/registry/examples/swipe-deck/discover.js +2 -3
  95. package/registry/examples/swipe-deck/discover.tsx +2 -3
  96. package/registry/examples/swipe-deck/native/discover.dark.png +0 -0
  97. package/registry/examples/swipe-deck/native/discover.js +2 -3
  98. package/registry/examples/swipe-deck/native/discover.light.png +0 -0
  99. package/registry/examples/swipe-deck/native/discover.tsx +2 -3
  100. package/registry/examples/vertical-video/feed.d.ts +4 -0
  101. package/registry/examples/vertical-video/feed.js +40 -0
  102. package/registry/examples/vertical-video/feed.tsx +36 -0
  103. package/registry/examples/vertical-video/native/rail.d.ts +4 -0
  104. package/registry/examples/vertical-video/native/rail.dark.png +0 -0
  105. package/registry/examples/vertical-video/native/rail.js +39 -0
  106. package/registry/examples/vertical-video/native/rail.light.png +0 -0
  107. package/registry/examples/vertical-video/native/rail.tsx +37 -0
  108. package/registry/examples/vertical-video/rail.d.ts +4 -0
  109. package/registry/examples/vertical-video/rail.js +39 -0
  110. package/registry/examples/vertical-video/rail.tsx +39 -0
  111. package/registry/field.json +1 -1
  112. package/registry/file-upload.json +1 -1
  113. package/registry/filter-bar.json +1 -1
  114. package/registry/form.json +1 -1
  115. package/registry/gallery-page.json +1 -1
  116. package/registry/glow-card.json +1 -1
  117. package/registry/goal-gauge.json +1 -1
  118. package/registry/how-it-works-page.json +1 -1
  119. package/registry/image-cropper.json +1 -1
  120. package/registry/index.json +27 -4
  121. package/registry/iso.json +1 -1
  122. package/registry/kanban.json +1 -1
  123. package/registry/kbd.json +1 -1
  124. package/registry/landing-page.json +1 -1
  125. package/registry/landscape.json +1 -1
  126. package/registry/legal-page.json +1 -1
  127. package/registry/link-card.json +1 -1
  128. package/registry/list.json +1 -1
  129. package/registry/loader.json +1 -1
  130. package/registry/longform-page.json +1 -1
  131. package/registry/map.json +2 -2
  132. package/registry/marketing-layout.json +1 -1
  133. package/registry/marquee.json +1 -1
  134. package/registry/mascot-quote.json +1 -1
  135. package/registry/mascot-state.json +1 -1
  136. package/registry/mascots.json +1 -1
  137. package/registry/match-dialog.json +1 -1
  138. package/registry/media-capture.json +1 -1
  139. package/registry/meter.json +1 -1
  140. package/registry/nav-bar.json +1 -1
  141. package/registry/number-ticker.json +1 -1
  142. package/registry/page-header.json +1 -1
  143. package/registry/page-hero.json +1 -1
  144. package/registry/page.json +1 -1
  145. package/registry/pagination.json +1 -1
  146. package/registry/photo-grid.json +1 -1
  147. package/registry/pointer-light.json +1 -1
  148. package/registry/popover.json +1 -1
  149. package/registry/pricing-page.json +1 -1
  150. package/registry/product-page.json +1 -1
  151. package/registry/profile-card.json +5 -5
  152. package/registry/progress.json +1 -1
  153. package/registry/promise-card.json +1 -1
  154. package/registry/radio-group.json +1 -1
  155. package/registry/screen.json +5 -5
  156. package/registry/scroll-stage.json +1 -1
  157. package/registry/search-field.json +1 -1
  158. package/registry/section-backdrop.json +1 -1
  159. package/registry/separator.json +1 -1
  160. package/registry/settings-layout.json +1 -1
  161. package/registry/skeleton.json +1 -1
  162. package/registry/skip-link.json +1 -1
  163. package/registry/slider.json +1 -1
  164. package/registry/slot.json +1 -1
  165. package/registry/spinner.json +1 -1
  166. package/registry/split-view.json +1 -1
  167. package/registry/stage-banner.json +1 -1
  168. package/registry/stat.json +1 -1
  169. package/registry/status-page.json +1 -1
  170. package/registry/step-progress.json +1 -1
  171. package/registry/stepper.json +1 -1
  172. package/registry/swipe-deck.json +4 -4
  173. package/registry/switch.json +1 -1
  174. package/registry/tabs.json +1 -1
  175. package/registry/tag-input.json +1 -1
  176. package/registry/tag-selector.json +1 -1
  177. package/registry/terminal-replay.json +1 -1
  178. package/registry/theme.json +1 -1
  179. package/registry/toast.json +1 -1
  180. package/registry/toc.json +1 -1
  181. package/registry/tooltip.json +1 -1
  182. package/registry/tree-view.json +1 -1
  183. package/registry/verified-badge.json +1 -1
  184. package/registry/vertical-video.json +871 -0
  185. package/registry/video-player.json +1 -1
  186. package/registry/video-trimmer.json +1 -1
  187. package/registry/virtual.json +1 -1
  188. /package/dist/{chunk-F2GKFIYK.js.map → chunk-CURW36FI.js.map} +0 -0
  189. /package/dist/{chunk-ECSCA3CN.js.map → chunk-UOMP6VOZ.js.map} +0 -0
@@ -0,0 +1,871 @@
1
+ {
2
+ "name": "vertical-video",
3
+ "type": "primitive",
4
+ "title": "Vertical video",
5
+ "summary": "A 9:16 short video with the action rail of a short-video feed — follow, like, comments, share, your own tip button — and the creator's caption over the picture; `ShortFeed` stacks them as a snap-scrolling feed that plays the one in view.",
6
+ "description": "A 9:16 short video with the action rail of a short-video feed — follow, like, comments, share, your own tip button — and the creator's caption over the picture; `ShortFeed` stacks them as a snap-scrolling feed that plays the one in view.",
7
+ "status": "beta",
8
+ "deprecated": null,
9
+ "category": "media",
10
+ "platforms": "both",
11
+ "whenToUse": [
12
+ "A creator's short clips: a feed, stories, an explore grid that opens on a video.",
13
+ "A phone-first product page where the video is the page (Orochia's feed, the home gallery's phone).",
14
+ "With `tipSlot`, `topStart` (an auction's time left) and `aboveCaption` (a goal gauge) for what is the product's own."
15
+ ],
16
+ "whenNotToUse": [
17
+ {
18
+ "when": "For a watch page with a seek bar, subtitles and speed: the full player.",
19
+ "use": "video-player"
20
+ },
21
+ {
22
+ "when": "For a sound without a picture.",
23
+ "use": "audio-player"
24
+ }
25
+ ],
26
+ "bestPractices": [
27
+ "Give every word in `labels` and a `title` that names the clip; the rail's buttons are named, 44 px and say their state (`aria-pressed`).",
28
+ "Counts are compact in the viewer's `locale` (12.4K): pass numbers, not strings.",
29
+ "In a `ShortFeed`, pass `active` and `preload` from `renderItem`: the one in view plays, the next preloads, the rest pause and stay unmounted.",
30
+ "Put product actions in slots, never a fork: a tip, an unlock, a report."
31
+ ],
32
+ "accessibility": {
33
+ "keyboard": [
34
+ {
35
+ "keys": "Space / K",
36
+ "action": "Play or pause the focused video."
37
+ },
38
+ {
39
+ "keys": "L",
40
+ "action": "Like it."
41
+ },
42
+ {
43
+ "keys": "Arrow Up / Arrow Down",
44
+ "action": "In a feed: the previous or next video, the focus follows."
45
+ },
46
+ {
47
+ "keys": "Tab",
48
+ "action": "The tap target, then the rail's buttons, then the caption's “More”."
49
+ }
50
+ ],
51
+ "notes": [
52
+ "Each video is an `article` named by `title`; the picture is one button (tap to pause or play, double tap to like) and the rail a named group.",
53
+ "The progress hairline is a `progressbar` named by its label.",
54
+ "Under reduced motion nothing autoplays: the poster and a play button; the like heart shows without growing.",
55
+ "Over the picture everything reads on `scrim` and `fg-on-media`, which do not change with the theme."
56
+ ]
57
+ },
58
+ "related": [
59
+ "video-player",
60
+ "profile-card",
61
+ "avatar"
62
+ ],
63
+ "web": {
64
+ "entry": "@krizaka/ui/vertical-video",
65
+ "import": "import { VerticalVideo, ShortFeed, verticalVideoLabels } from \"@krizaka/ui/vertical-video\";",
66
+ "props": [
67
+ {
68
+ "component": "ShortFeed",
69
+ "description": "A vertical snap-scrolling feed that plays the video in view, preloads the next and pauses the rest.",
70
+ "props": [
71
+ {
72
+ "name": "getId",
73
+ "type": "(item: T) => string",
74
+ "default": null,
75
+ "description": "A stable key for an item.",
76
+ "required": true
77
+ },
78
+ {
79
+ "name": "items",
80
+ "type": "readonly T[]",
81
+ "default": null,
82
+ "description": "The videos, in order.",
83
+ "required": true
84
+ },
85
+ {
86
+ "name": "label",
87
+ "type": "string",
88
+ "default": null,
89
+ "description": "The feed's accessible name (\"Videos\"), passed translated.",
90
+ "required": true
91
+ },
92
+ {
93
+ "name": "renderItem",
94
+ "type": "(item: T, state: { active: boolean; preload: \"none\" | \"auto\" | \"metadata\"; index: number; }) => ReactNode",
95
+ "default": null,
96
+ "description": "One slide: `active` is true for the one in view, `preload` for the next, `near` false for the ones kept empty.",
97
+ "required": true
98
+ },
99
+ {
100
+ "name": "onActiveChange",
101
+ "type": "((index: number, item: T) => void)",
102
+ "default": null,
103
+ "description": "Called when the slide in view changes.",
104
+ "required": false
105
+ },
106
+ {
107
+ "name": "window",
108
+ "type": "number",
109
+ "default": "1",
110
+ "description": "The slides mounted around the one in view (before and after). Default 1; the others keep their height empty.",
111
+ "required": false
112
+ }
113
+ ]
114
+ },
115
+ {
116
+ "component": "VerticalVideo",
117
+ "description": "One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left, a progress hairline.",
118
+ "props": [
119
+ {
120
+ "name": "creator",
121
+ "type": "VerticalVideoCreator",
122
+ "default": null,
123
+ "description": "Who made it: name, portrait, the seal's words.",
124
+ "required": true
125
+ },
126
+ {
127
+ "name": "title",
128
+ "type": "string",
129
+ "default": null,
130
+ "description": "The video's accessible name (\"Video: Night shoot, part 2\"), passed translated.",
131
+ "required": true
132
+ },
133
+ {
134
+ "name": "aboveCaption",
135
+ "type": "ReactNode",
136
+ "default": null,
137
+ "description": "Above the caption: a goal gauge, a challenge's progress.",
138
+ "required": false
139
+ },
140
+ {
141
+ "name": "active",
142
+ "type": "boolean",
143
+ "default": "true",
144
+ "description": "Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`.",
145
+ "required": false
146
+ },
147
+ {
148
+ "name": "caption",
149
+ "type": "string",
150
+ "default": null,
151
+ "description": "The caption: collapsed to two lines, expandable when long.",
152
+ "required": false
153
+ },
154
+ {
155
+ "name": "comments",
156
+ "type": "number",
157
+ "default": null,
158
+ "description": "The comment count under its button.",
159
+ "required": false
160
+ },
161
+ {
162
+ "name": "credit",
163
+ "type": "string",
164
+ "default": null,
165
+ "description": "The sound or credit line (\"Original sound — Elena\").",
166
+ "required": false
167
+ },
168
+ {
169
+ "name": "disclosure",
170
+ "type": "string",
171
+ "default": null,
172
+ "description": "A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote.",
173
+ "required": false
174
+ },
175
+ {
176
+ "name": "following",
177
+ "type": "boolean",
178
+ "default": "false",
179
+ "description": "Whether the viewer follows the creator: the \"+\" on the avatar becomes a check.",
180
+ "required": false
181
+ },
182
+ {
183
+ "name": "labels",
184
+ "type": "Partial<VerticalVideoLabels>",
185
+ "default": null,
186
+ "description": "Every word, passed translated.",
187
+ "required": false
188
+ },
189
+ {
190
+ "name": "liked",
191
+ "type": "boolean",
192
+ "default": "false",
193
+ "description": "Whether the viewer likes it: `aria-pressed` on the like button.",
194
+ "required": false
195
+ },
196
+ {
197
+ "name": "likes",
198
+ "type": "number",
199
+ "default": null,
200
+ "description": "The like count under its button, compact (`12.4K`) in `locale`.",
201
+ "required": false
202
+ },
203
+ {
204
+ "name": "locale",
205
+ "type": "string",
206
+ "default": "en",
207
+ "description": "The locale of the counts. Default `en`.",
208
+ "required": false
209
+ },
210
+ {
211
+ "name": "loop",
212
+ "type": "boolean",
213
+ "default": "true",
214
+ "description": "Plays again at the end. Default `true`.",
215
+ "required": false
216
+ },
217
+ {
218
+ "name": "muted",
219
+ "type": "boolean",
220
+ "default": "true",
221
+ "description": "Starts muted (autoplay needs it). Default `true`.",
222
+ "required": false
223
+ },
224
+ {
225
+ "name": "onComment",
226
+ "type": "(() => void)",
227
+ "default": null,
228
+ "description": "Opens the comments; without it the button still shows.",
229
+ "required": false
230
+ },
231
+ {
232
+ "name": "onFollow",
233
+ "type": "((following: boolean) => void)",
234
+ "default": null,
235
+ "description": "The viewer follows or unfollows; without it the avatar has no \"+\".",
236
+ "required": false
237
+ },
238
+ {
239
+ "name": "onLike",
240
+ "type": "((liked: boolean) => void)",
241
+ "default": null,
242
+ "description": "The viewer likes or unlikes (the button, `L`, a double tap).",
243
+ "required": false
244
+ },
245
+ {
246
+ "name": "onMore",
247
+ "type": "(() => void)",
248
+ "default": null,
249
+ "description": "The \"more\" button's action (report, hide); without it the button is not drawn.",
250
+ "required": false
251
+ },
252
+ {
253
+ "name": "onShare",
254
+ "type": "(() => void)",
255
+ "default": null,
256
+ "description": "Opens the share sheet.",
257
+ "required": false
258
+ },
259
+ {
260
+ "name": "poster",
261
+ "type": "string",
262
+ "default": null,
263
+ "description": "The picture until the first frame, and under reduced motion.",
264
+ "required": false
265
+ },
266
+ {
267
+ "name": "preload",
268
+ "type": "\"none\" | \"auto\" | \"metadata\"",
269
+ "default": "metadata",
270
+ "description": "The next slide's: `auto` loads it ahead, `none` waits. Default `metadata`.",
271
+ "required": false
272
+ },
273
+ {
274
+ "name": "shares",
275
+ "type": "number",
276
+ "default": null,
277
+ "description": "The share count under its button.",
278
+ "required": false
279
+ },
280
+ {
281
+ "name": "src",
282
+ "type": "string",
283
+ "default": null,
284
+ "description": "The clip — absent, the poster alone shows (MP4 or WebM; an HLS address only in Safari — use the player of `@krizaka/ui/video-player` for HLS).",
285
+ "required": false
286
+ },
287
+ {
288
+ "name": "tags",
289
+ "type": "readonly string[]",
290
+ "default": "[]",
291
+ "description": "Tags under the caption (without `#`).",
292
+ "required": false
293
+ },
294
+ {
295
+ "name": "tipSlot",
296
+ "type": "ReactNode",
297
+ "default": null,
298
+ "description": "The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named).",
299
+ "required": false
300
+ },
301
+ {
302
+ "name": "topStart",
303
+ "type": "ReactNode",
304
+ "default": null,
305
+ "description": "Top left: a chip over the picture (an auction's time left).",
306
+ "required": false
307
+ }
308
+ ]
309
+ }
310
+ ],
311
+ "examples": [
312
+ {
313
+ "name": "rail",
314
+ "title": "The rail and the caption",
315
+ "description": "Follow, like, comments, share and a tip button on the right; creator, caption, tags and credit bottom-left.",
316
+ "path": "examples/vertical-video/rail.tsx",
317
+ "module": "@krizaka/ui/registry/examples/vertical-video/rail",
318
+ "code": "\"use client\";\n\nimport { Button } from \"@krizaka/ui/button\";\nimport { VerticalVideo } from \"@krizaka/ui/vertical-video\";\nimport { useState } from \"react\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site/people/ui\";\n\n// One short video (`src` and `poster` are yours: a clip of your own): the rail on the right (follow, like, comments, share, your own tip button), the caption bottom-left.\n// Tap pauses, double tap likes, Space pauses and L likes on the focused video.\nexport default function VerticalVideoRail({ src, poster = `${CDN}/camille-512.9573eaa0b8.webp` }: { src?: string; poster?: string } = {}) {\n const [liked, setLiked] = useState(false);\n const [following, setFollowing] = useState(false);\n return (\n <div className=\"w-64\">\n <VerticalVideo\n title=\"Video: Night shoot, part 2\"\n src={src}\n poster={poster}\n active={false}\n creator={{ name: \"Camille\", avatarSrc: `${CDN}/camille-256.dc2943029a.webp`, verifiedLabel: \"Verified\" }}\n caption=\"The rooftop, the rain, the last take at dawn.\"\n tags={[\"nightshoot\", \"montreal\"]}\n disclosure=\"AI-generated\"\n credit=\"Original sound — Camille\"\n likes={12400 + (liked ? 1 : 0)}\n comments={328}\n shares={95}\n liked={liked}\n following={following}\n onLike={setLiked}\n onFollow={setFollowing}\n onComment={() => undefined}\n onShare={() => undefined}\n tipSlot={<Button size=\"sm\" variant=\"primary\" shape=\"pill\">Tip</Button>}\n />\n </div>\n );\n}\n"
319
+ },
320
+ {
321
+ "name": "feed",
322
+ "title": "A feed",
323
+ "description": "`ShortFeed`: one video per screen, the one in view plays, the next preloads; ↑ ↓ move.",
324
+ "path": "examples/vertical-video/feed.tsx",
325
+ "module": "@krizaka/ui/registry/examples/vertical-video/feed",
326
+ "code": "\"use client\";\n\nimport { ShortFeed, VerticalVideo } from \"@krizaka/ui/vertical-video\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site/people/ui\";\nconst CLIPS = [\n { id: \"a\", who: \"Camille\", face: `${CDN}/camille-256.dc2943029a.webp`, caption: \"Night shoot, part 2.\" },\n { id: \"b\", who: \"Tomás\", face: `${CDN}/tomas-256.7eaab80903.webp`, caption: \"Sunday market, slow.\" },\n { id: \"c\", who: \"Noor\", face: `${CDN}/noor-256.c340c41421.webp`, caption: \"Backstage cut.\" },\n];\n\n// A feed: one video per screen, the one in view plays, the next preloads, the others pause; ↑ ↓ move between them.\nexport default function VerticalVideoFeed({ src }: { src?: string } = {}) {\n return (\n <ShortFeed\n label=\"Videos\"\n className=\"h-[34rem] w-64\"\n items={CLIPS}\n getId={(clip) => clip.id}\n renderItem={(clip, { active, preload }) => (\n <VerticalVideo\n title={`Video by ${clip.who}`}\n src={src}\n poster={clip.face}\n active={active}\n preload={preload}\n creator={{ name: clip.who, avatarSrc: clip.face }}\n caption={clip.caption}\n likes={1200}\n comments={48}\n onLike={() => undefined}\n />\n )}\n />\n );\n}\n"
327
+ }
328
+ ]
329
+ },
330
+ "native": {
331
+ "entry": "@krizaka/ui/native/vertical-video",
332
+ "import": "import { VerticalVideo, ShortFeed, verticalVideoLabels } from \"@krizaka/ui/native/vertical-video\";",
333
+ "differences": [
334
+ "Its own entry (`@krizaka/ui/native/vertical-video`): it plays on expo-video, an optional peer.",
335
+ "`uri` and `posterUri` instead of `src` and `poster`; `creator.avatarUri`; a long press likes (there is no double click).",
336
+ "`ShortFeed` pages a FlatList: give it the page's `height`; the slide in view is the viewable one."
337
+ ],
338
+ "props": [
339
+ {
340
+ "component": "VerticalVideo",
341
+ "description": "One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left.",
342
+ "props": [
343
+ {
344
+ "name": "creator",
345
+ "type": "{ name: string; avatarUri?: string | undefined; verifiedLabel?: string | undefined; }",
346
+ "default": null,
347
+ "description": "Who made it: name, portrait, the seal's words.",
348
+ "required": true
349
+ },
350
+ {
351
+ "name": "title",
352
+ "type": "string",
353
+ "default": null,
354
+ "description": "The video's accessible name (\"Video: Night shoot, part 2\"), passed translated.",
355
+ "required": true
356
+ },
357
+ {
358
+ "name": "aboveCaption",
359
+ "type": "ReactNode",
360
+ "default": null,
361
+ "description": "Above the caption: a goal gauge.",
362
+ "required": false
363
+ },
364
+ {
365
+ "name": "active",
366
+ "type": "boolean",
367
+ "default": "true",
368
+ "description": "Plays while `true` (a `ShortFeed` passes it for the slide in view). Default `true`.",
369
+ "required": false
370
+ },
371
+ {
372
+ "name": "caption",
373
+ "type": "string",
374
+ "default": null,
375
+ "description": "The caption, two lines until expanded.",
376
+ "required": false
377
+ },
378
+ {
379
+ "name": "comments",
380
+ "type": "number",
381
+ "default": null,
382
+ "description": "The comment count.",
383
+ "required": false
384
+ },
385
+ {
386
+ "name": "credit",
387
+ "type": "string",
388
+ "default": null,
389
+ "description": "The sound or credit line.",
390
+ "required": false
391
+ },
392
+ {
393
+ "name": "disclosure",
394
+ "type": "string",
395
+ "default": null,
396
+ "description": "A pill beside the name (\"AI-GENERATED\").",
397
+ "required": false
398
+ },
399
+ {
400
+ "name": "following",
401
+ "type": "boolean",
402
+ "default": "false",
403
+ "description": "Whether the viewer follows the creator.",
404
+ "required": false
405
+ },
406
+ {
407
+ "name": "labels",
408
+ "type": "Partial<VerticalVideoLabels>",
409
+ "default": null,
410
+ "description": "Every word, passed translated.",
411
+ "required": false
412
+ },
413
+ {
414
+ "name": "liked",
415
+ "type": "boolean",
416
+ "default": "false",
417
+ "description": "Whether the viewer likes it.",
418
+ "required": false
419
+ },
420
+ {
421
+ "name": "likes",
422
+ "type": "number",
423
+ "default": null,
424
+ "description": "The like count, compact in `locale`.",
425
+ "required": false
426
+ },
427
+ {
428
+ "name": "locale",
429
+ "type": "string",
430
+ "default": "en",
431
+ "description": "The locale of the counts. Default `en`.",
432
+ "required": false
433
+ },
434
+ {
435
+ "name": "onComment",
436
+ "type": "(() => void)",
437
+ "default": null,
438
+ "description": "Opens the comments.",
439
+ "required": false
440
+ },
441
+ {
442
+ "name": "onFollow",
443
+ "type": "((following: boolean) => void)",
444
+ "default": null,
445
+ "description": "Follows or unfollows; without it the avatar has no \"+\".",
446
+ "required": false
447
+ },
448
+ {
449
+ "name": "onLike",
450
+ "type": "((liked: boolean) => void)",
451
+ "default": null,
452
+ "description": "The viewer likes or unlikes.",
453
+ "required": false
454
+ },
455
+ {
456
+ "name": "onMore",
457
+ "type": "(() => void)",
458
+ "default": null,
459
+ "description": "The \"more\" action; without it the button is not drawn.",
460
+ "required": false
461
+ },
462
+ {
463
+ "name": "onShare",
464
+ "type": "(() => void)",
465
+ "default": null,
466
+ "description": "Opens the share sheet.",
467
+ "required": false
468
+ },
469
+ {
470
+ "name": "posterUri",
471
+ "type": "string",
472
+ "default": null,
473
+ "description": "The picture until the first frame.",
474
+ "required": false
475
+ },
476
+ {
477
+ "name": "shares",
478
+ "type": "number",
479
+ "default": null,
480
+ "description": "The share count.",
481
+ "required": false
482
+ },
483
+ {
484
+ "name": "style",
485
+ "type": "StyleProp<ViewStyle>",
486
+ "default": null,
487
+ "description": "Styles merged last, on the frame.",
488
+ "required": false
489
+ },
490
+ {
491
+ "name": "tags",
492
+ "type": "readonly string[]",
493
+ "default": "[]",
494
+ "description": "Tags after the caption (without `#`).",
495
+ "required": false
496
+ },
497
+ {
498
+ "name": "tipSlot",
499
+ "type": "ReactNode",
500
+ "default": null,
501
+ "description": "The product's own action on the rail (a tip, an unlock): a named, 44 pt button of yours.",
502
+ "required": false
503
+ },
504
+ {
505
+ "name": "topStart",
506
+ "type": "ReactNode",
507
+ "default": null,
508
+ "description": "Top left over the picture: a chip (an auction's time left).",
509
+ "required": false
510
+ },
511
+ {
512
+ "name": "uri",
513
+ "type": "string",
514
+ "default": null,
515
+ "description": "The clip's address (MP4 or HLS). Absent, the poster alone shows.",
516
+ "required": false
517
+ }
518
+ ]
519
+ },
520
+ {
521
+ "component": "ShortFeed",
522
+ "description": "A vertical paging feed: one video per screen, the one in view plays, the others pause.",
523
+ "props": [
524
+ {
525
+ "name": "getId",
526
+ "type": "(item: T) => string",
527
+ "default": null,
528
+ "description": "A stable key for an item.",
529
+ "required": true
530
+ },
531
+ {
532
+ "name": "height",
533
+ "type": "number",
534
+ "default": null,
535
+ "description": "The page's height in points (the feed's own height; one slide per page).",
536
+ "required": true
537
+ },
538
+ {
539
+ "name": "items",
540
+ "type": "readonly T[]",
541
+ "default": null,
542
+ "description": "The videos, in order.",
543
+ "required": true
544
+ },
545
+ {
546
+ "name": "label",
547
+ "type": "string",
548
+ "default": null,
549
+ "description": "The feed's accessible name (\"Videos\").",
550
+ "required": true
551
+ },
552
+ {
553
+ "name": "renderItem",
554
+ "type": "(item: T, state: { active: boolean; index: number; }) => ReactElement<unknown, string | JSXElementConstructor<any>>",
555
+ "default": null,
556
+ "description": "One slide: `active` is true for the one in view.",
557
+ "required": true
558
+ },
559
+ {
560
+ "name": "onActiveChange",
561
+ "type": "((index: number, item: T) => void)",
562
+ "default": null,
563
+ "description": "Called when the slide in view changes.",
564
+ "required": false
565
+ },
566
+ {
567
+ "name": "style",
568
+ "type": "StyleProp<ViewStyle>",
569
+ "default": null,
570
+ "description": "Styles merged last.",
571
+ "required": false
572
+ }
573
+ ]
574
+ }
575
+ ],
576
+ "examples": [
577
+ {
578
+ "name": "rail",
579
+ "title": "The rail and the caption",
580
+ "description": "The same composition on a phone: follow, like, comments, share and your tip button; caption bottom-left.",
581
+ "path": "examples/vertical-video/native/rail.tsx",
582
+ "module": "@krizaka/ui/registry/examples/vertical-video/native/rail",
583
+ "code": "import { Button } from \"@krizaka/ui/native\";\nimport { VerticalVideo } from \"@krizaka/ui/native/vertical-video\";\nimport { useState } from \"react\";\nimport { View } from \"react-native\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site/people/ui\";\n\n// One short video: the rail on the right (follow, like, comments, share, your own tip button), the caption bottom-left.\n// `uri` is your clip; without it the poster shows. A long press likes.\nexport default function NativeVerticalVideoRail({ uri }: { uri?: string } = {}) {\n const [liked, setLiked] = useState(false);\n const [following, setFollowing] = useState(false);\n return (\n <View style={{ width: 260 }}>\n <VerticalVideo\n title=\"Video: Night shoot, part 2\"\n {...(uri ? { uri } : {})}\n posterUri={`${CDN}/camille-512.9573eaa0b8.webp`}\n creator={{ name: \"Camille\", avatarUri: `${CDN}/camille-256.dc2943029a.webp`, verifiedLabel: \"Verified\" }}\n disclosure=\"AI-generated\"\n caption=\"The rooftop, the rain, the last take at dawn.\"\n tags={[\"nightshoot\"]}\n credit=\"Original sound — Camille\"\n likes={12400 + (liked ? 1 : 0)}\n comments={328}\n shares={95}\n liked={liked}\n following={following}\n onLike={setLiked}\n onFollow={setFollowing}\n onComment={() => undefined}\n onShare={() => undefined}\n tipSlot={<Button label=\"Tip\" size=\"sm\" variant=\"primary\" />}\n />\n </View>\n );\n}\n",
584
+ "screenshots": {
585
+ "dark": "examples/vertical-video/native/rail.dark.png",
586
+ "light": "examples/vertical-video/native/rail.light.png"
587
+ }
588
+ }
589
+ ]
590
+ },
591
+ "files": [
592
+ {
593
+ "path": "meta.ts",
594
+ "content": "// The documentation of a component, carried by its code: each primitive has a `meta.ts` beside its source\n// (`src/<name>/meta.ts`; a component that exists only in React Native: `src/native/meta/<name>.ts`). Never built into\n// the package: `scripts/build-registry.mjs` reads them into `registry/<name>.json`, which krizaka.com renders as\n// /docs/ui/<name>, and `scripts/registry.test.mjs` fails when one is missing or incomplete.\n//\n// Why a typed module and not JSDoc tags: the fields are lists and links (`related`, `whenNotToUse[].use`, examples),\n// which a type checks and a comment does not; the file is plain data (erasable TypeScript), so Node reads it without a\n// compiler, and an editor completes it.\n\n/** Every documented component: the web primitives (one entry of the package each) and the native-only ones. */\nexport type ComponentName =\n | \"achievement\"\n | \"action-bar\"\n | \"activity-feed\"\n | \"action-sheet\"\n | \"alert\"\n | \"app-shell\"\n | \"argument-card\"\n | \"audio-player\"\n | \"auth-layout\"\n | \"avatar\"\n | \"badge\"\n | \"button\"\n | \"card\"\n | \"card-grid\"\n | \"catalogue-page\"\n | \"changelog-page\"\n | \"chat\"\n | \"checkbox\"\n | \"chip\"\n | \"chip-link\"\n | \"choice-grid\"\n | \"cn\"\n | \"code-block\"\n | \"combobox\"\n | \"command\"\n | \"component-page\"\n | \"confirm-button\"\n | \"contact-page\"\n | \"continue-prompt\"\n | \"countdown\"\n | \"cta-panel\"\n | \"dashboard-layout\"\n | \"data-table\"\n | \"date-picker\"\n | \"decision-list\"\n | \"description-list\"\n | \"detail-layout\"\n | \"dialog\"\n | \"docs-article-page\"\n | \"docs-hub-page\"\n | \"docs-layout\"\n | \"dropdown-menu\"\n | \"empty-state\"\n | \"error-page\"\n | \"fab\"\n | \"field\"\n | \"file-upload\"\n | \"filter-bar\"\n | \"form\"\n | \"gallery-page\"\n | \"glow-card\"\n | \"goal-gauge\"\n | \"header\"\n | \"how-it-works-page\"\n | \"image-cropper\"\n | \"image-viewer\"\n | \"iso\"\n | \"kanban\"\n | \"kbd\"\n | \"landing-page\"\n | \"landscape\"\n | \"legal-page\"\n | \"link-card\"\n | \"list\"\n | \"loader\"\n | \"map\"\n | \"longform-page\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"match-dialog\"\n | \"media-capture\"\n | \"media-picker\"\n | \"meter\"\n | \"nav-bar\"\n | \"number-ticker\"\n | \"onboarding\"\n | \"otp-input\"\n | \"page\"\n | \"page-header\"\n | \"page-hero\"\n | \"pagination\"\n | \"permission-prompt\"\n | \"photo-grid\"\n | \"pointer-light\"\n | \"popover\"\n | \"pricing-page\"\n | \"product-page\"\n | \"profile-card\"\n | \"progress\"\n | \"promise-card\"\n | \"pull-to-refresh\"\n | \"radio-group\"\n | \"screen\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"settings-layout\"\n | \"skeleton\"\n | \"skip-link\"\n | \"slider\"\n | \"slot\"\n | \"spinner\"\n | \"split-view\"\n | \"splash-handoff\"\n | \"stage-banner\"\n | \"stat\"\n | \"status-page\"\n | \"step-progress\"\n | \"stepper\"\n | \"swipe-deck\"\n | \"swipeable-row\"\n | \"switch\"\n | \"tab-bar\"\n | \"tabs\"\n | \"tag-input\"\n | \"tag-selector\"\n | \"terminal-replay\"\n | \"theme\"\n | \"toast\"\n | \"toc\"\n | \"tooltip\"\n | \"tree-view\"\n | \"txt\"\n | \"verified-badge\"\n | \"vertical-video\"\n | \"video-player\"\n | \"video-trimmer\"\n | \"virtual\";\n\n/** Where a component exists: `@krizaka/ui/<name>` (web), `@krizaka/ui/native` (React Native), or both. */\nexport type Platform = \"web\" | \"native\" | \"both\";\n\n/**\n * `stable`: its API only changes in a major. `beta`: it may still change in a minor. `deprecated`: no product uses it and\n * no work plans to — it still works, says what replaces it, and goes in the major named by `deprecated.removedIn`.\n */\nexport type Status = \"stable\" | \"beta\" | \"deprecated\";\n\n/** What a deprecated component says (required when `status` is `deprecated`, absent otherwise). */\nexport interface Deprecation {\n /** The version that deprecated it (`3.6.0`). */\n readonly since: string;\n /** The major that removes it (`4.0.0`). */\n readonly removedIn: string;\n /** What replaces it, when a component does. */\n readonly use?: ComponentName;\n /** Why it goes: no product uses it, no planned work needs it. */\n readonly reason: string;\n}\n\n/**\n * The group of the catalogue. `media`: video, sound, camera, files, cropping. `blocks`: the page blocks — the frame,\n * the hero, sections, cards that lead or argue, decisions, code, calls to act — that every page of a site is made of.\n * `layouts`: the shells of whole screens — an application, a dashboard, settings, sign-in, an object, a list and its\n * item, a site's page, a documentation page — each with its screens. `templates`: the page templates — whole pages of a\n * site (a landing, a product, a docs hub, a long read, a changelog, pricing, status, an error…), each a distinct\n * composition of the blocks, words and content as props.\n */\nexport type Category = \"actions\" | \"forms\" | \"navigation\" | \"overlays\" | \"feedback\" | \"data-display\" | \"media\" | \"blocks\" | \"layouts\" | \"templates\" | \"layout\" | \"foundations\";\n\n/** Why a reader needs a template: the seven intents the templates are grouped by (sidebar sub-headings, catalogue filter). */\nexport type TemplatePurpose = \"convince\" | \"explain\" | \"show\" | \"act\" | \"operate\" | \"converse\" | \"recover\";\n\n/** The groups of templates, in reading order: the title, and the one line that says when to come to that group. */\nexport const templatePurposes = {\n convince: { title: \"Convince\", useWhen: \"You come here when a visitor must decide: a landing, a product, a price, a comparison, a proof.\" },\n explain: { title: \"Explain\", useWhen: \"You come here when a reader must understand: how it works, a documentation hub or article, a changelog, a FAQ, a policy.\" },\n show: { title: \"Show\", useWhen: \"You come here when the work itself is the argument: a gallery, a story, a case study, an object and its details.\" },\n act: { title: \"Act\", useWhen: \"You come here when a person must do something once: reach the team, sign in or up, set up, pay.\" },\n operate: { title: \"Operate\", useWhen: \"You come here when a person works every day: a dashboard, a console with a table, settings, an app shell.\" },\n converse: { title: \"Converse\", useWhen: \"You come here when people talk: an inbox, a chat, comments, a notifications centre.\" },\n recover: { title: \"Recover\", useWhen: \"You come here when the way is blocked: an empty list, an error, a missing page, maintenance, a product still being built.\" },\n} as const satisfies Record<TemplatePurpose, { readonly title: string; readonly useWhen: string }>;\n\n/**\n * A named example: the file `registry/examples/<component>/<name>.tsx` (web) or\n * `registry/examples/<component>/native/<name>.tsx` (React Native) — at most 40 lines, imported by the stories and\n * shown (live, with its code) by the documentation.\n */\ninterface ExampleBase {\n /** The file name, kebab-case (`primary`, `icon-button`). */\n readonly name: string;\n /** A short title (`Primary`, `Icon button`). */\n readonly title: string;\n /** One or two sentences: when this variant is the right one. */\n readonly description: string;\n}\n\n/** A plain example, or a template: a whole screen with its intent (`purpose`) and the user goal its layout serves (`why`). */\nexport type Example =\n | (ExampleBase & { readonly template?: false; readonly purpose?: undefined; readonly why?: undefined })\n | (ExampleBase & {\n /**\n * A template: a whole screen of a product composed from the platform (a layout and its blocks, real words, its\n * phone version thought out), copied as a starting point — at most 160 lines, listed on krizaka.com/docs/ui/templates.\n */\n readonly template: true;\n /** The intent that groups it: why you need this block. */\n readonly purpose: TemplatePurpose;\n /** Why this layout: the user goal it serves, in one sentence. */\n readonly why: string;\n });\n\n/** One platform's side of a component. */\nexport interface PlatformDocs {\n /** The names to import (`[\"Button\", \"IconButton\"]`): from `@krizaka/ui/<name>` on the web, `@krizaka/ui/native` in React Native. */\n readonly imports: readonly string[];\n /** The examples, in reading order; the first one is the component's preview. */\n readonly examples: readonly Example[];\n}\n\n/** The React Native side: the same concept, and what differs from the web API. */\nexport interface NativeDocs extends PlatformDocs {\n /**\n * The entry to import from when it is not `@krizaka/ui/native`: a component that needs an Expo module (an optional\n * peer) has its own, `@krizaka/ui/native/<name>`, so an app that does not use it never installs the module.\n */\n readonly entry?: string;\n /** What differs from the web API (`label` instead of children…); empty for a native-only component. */\n readonly differences: readonly string[];\n}\n\nexport interface KeyboardInteraction {\n /** The keys, as written on a keyboard: `Space`, `Enter`, `Arrow keys`, `Esc`, `Tab`. */\n readonly keys: string;\n /** What they do. */\n readonly action: string;\n}\n\nexport interface ComponentMeta {\n /** The display name: `Button`, `Dropdown menu`. */\n readonly title: string;\n /** One sentence: what it is. */\n readonly summary: string;\n readonly status: Status;\n /** Required for, and only for, a `deprecated` component. */\n readonly deprecated?: Deprecation;\n readonly category: Category;\n /** Must match the sides below: `both` ⇔ `web` and `native`. */\n readonly platforms: Platform;\n /** When to reach for it: concrete situations, one per item. */\n readonly whenToUse: readonly string[];\n /** When not to — and what to use instead (`use`), when another component is the answer. */\n readonly whenNotToUse: readonly { readonly when: string; readonly use?: ComponentName }[];\n /** How to use it well: wording, placement, composition. */\n readonly bestPractices: readonly string[];\n readonly accessibility: {\n /** The keyboard, when it does more than Tab and Enter. */\n readonly keyboard: readonly KeyboardInteraction[];\n /** Roles, names and announcements: what the component does, what the app must provide. */\n readonly notes: readonly string[];\n };\n /** The components a reader of this page should know about. */\n readonly related: readonly ComponentName[];\n readonly web?: PlatformDocs;\n readonly native?: NativeDocs;\n}\n"
595
+ },
596
+ {
597
+ "path": "vertical-video/index.ts",
598
+ "content": "/** @krizaka/ui/vertical-video — `VerticalVideo` (9:16, the action rail and caption of a short-video feed), `ShortFeed` (snap scroll, plays the one in view, preloads the next). Client. */\nexport { ShortFeed, type ShortFeedProps } from \"./short-feed\";\nexport { VerticalVideo, type VerticalVideoCreator, type VerticalVideoLabels, verticalVideoLabels, type VerticalVideoProps } from \"./vertical-video\";\n"
599
+ },
600
+ {
601
+ "path": "vertical-video/meta.ts",
602
+ "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Vertical video\",\n summary: \"A 9:16 short video with the action rail of a short-video feed — follow, like, comments, share, your own tip button — and the creator's caption over the picture; `ShortFeed` stacks them as a snap-scrolling feed that plays the one in view.\",\n status: \"beta\",\n category: \"media\",\n platforms: \"both\",\n whenToUse: [\n \"A creator's short clips: a feed, stories, an explore grid that opens on a video.\",\n \"A phone-first product page where the video is the page (Orochia's feed, the home gallery's phone).\",\n \"With `tipSlot`, `topStart` (an auction's time left) and `aboveCaption` (a goal gauge) for what is the product's own.\",\n ],\n whenNotToUse: [\n { when: \"For a watch page with a seek bar, subtitles and speed: the full player.\", use: \"video-player\" },\n { when: \"For a sound without a picture.\", use: \"audio-player\" },\n ],\n bestPractices: [\n \"Give every word in `labels` and a `title` that names the clip; the rail's buttons are named, 44 px and say their state (`aria-pressed`).\",\n \"Counts are compact in the viewer's `locale` (12.4K): pass numbers, not strings.\",\n \"In a `ShortFeed`, pass `active` and `preload` from `renderItem`: the one in view plays, the next preloads, the rest pause and stay unmounted.\",\n \"Put product actions in slots, never a fork: a tip, an unlock, a report.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Space / K\", action: \"Play or pause the focused video.\" },\n { keys: \"L\", action: \"Like it.\" },\n { keys: \"Arrow Up / Arrow Down\", action: \"In a feed: the previous or next video, the focus follows.\" },\n { keys: \"Tab\", action: \"The tap target, then the rail's buttons, then the caption's “More”.\" },\n ],\n notes: [\n \"Each video is an `article` named by `title`; the picture is one button (tap to pause or play, double tap to like) and the rail a named group.\",\n \"The progress hairline is a `progressbar` named by its label.\",\n \"Under reduced motion nothing autoplays: the poster and a play button; the like heart shows without growing.\",\n \"Over the picture everything reads on `scrim` and `fg-on-media`, which do not change with the theme.\",\n ],\n },\n related: [\"video-player\", \"profile-card\", \"avatar\"],\n web: {\n imports: [\"VerticalVideo\", \"ShortFeed\", \"verticalVideoLabels\"],\n examples: [\n { name: \"rail\", title: \"The rail and the caption\", description: \"Follow, like, comments, share and a tip button on the right; creator, caption, tags and credit bottom-left.\" },\n { name: \"feed\", title: \"A feed\", description: \"`ShortFeed`: one video per screen, the one in view plays, the next preloads; ↑ ↓ move.\" },\n ],\n },\n native: {\n entry: \"@krizaka/ui/native/vertical-video\",\n imports: [\"VerticalVideo\", \"ShortFeed\", \"verticalVideoLabels\"],\n differences: [\n \"Its own entry (`@krizaka/ui/native/vertical-video`): it plays on expo-video, an optional peer.\",\n \"`uri` and `posterUri` instead of `src` and `poster`; `creator.avatarUri`; a long press likes (there is no double click).\",\n \"`ShortFeed` pages a FlatList: give it the page's `height`; the slide in view is the viewable one.\",\n ],\n examples: [{ name: \"rail\", title: \"The rail and the caption\", description: \"The same composition on a phone: follow, like, comments, share and your tip button; caption bottom-left.\" }],\n },\n} satisfies ComponentMeta;"
603
+ },
604
+ {
605
+ "path": "vertical-video/short-feed.tsx",
606
+ "content": "// Client (an observer, keys). A vertical feed of short videos: one slide per screen, CSS scroll-snap, the slide in view\n// plays and the others pause, the next one preloads, only the slides near the one in view are mounted (the rest keep their\n// height). ↑/↓ (and PageUp/PageDown) go to the previous / next, with the focus following.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\n\nexport interface ShortFeedProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\"> {\n /** The feed's accessible name (\"Videos\"), passed translated. */\n label: string;\n /** The videos, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One slide: `active` is true for the one in view, `preload` for the next, `near` false for the ones kept empty. */\n renderItem: (item: T, state: { active: boolean; preload: \"none\" | \"metadata\" | \"auto\"; index: number }) => React.ReactNode;\n /** The slides mounted around the one in view (before and after). Default 1; the others keep their height empty. */\n window?: number;\n /** Called when the slide in view changes. */\n onActiveChange?: (index: number, item: T) => void;\n}\n\n/** A vertical snap-scrolling feed that plays the video in view, preloads the next and pauses the rest. */\nexport function ShortFeed<T>({ label, items, getId, renderItem, window: around = 1, onActiveChange, className, onKeyDown, ...props }: ShortFeedProps<T>) {\n const root = React.useRef<HTMLElement>(null);\n const [active, setActive] = React.useState(0);\n const slides = React.useRef<(HTMLDivElement | null)[]>([]);\n\n React.useEffect(() => {\n const host = root.current;\n if (!host || typeof IntersectionObserver === \"undefined\") return;\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting || entry.intersectionRatio < 0.6) continue;\n const index = Number((entry.target as HTMLElement).dataset.index);\n setActive(index);\n }\n },\n { root: host, threshold: [0.6] },\n );\n for (const slide of slides.current) if (slide) observer.observe(slide);\n return () => observer.disconnect();\n }, [items.length]);\n\n const first = React.useRef(true);\n React.useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n const item = items[active];\n if (item !== undefined) onActiveChange?.(active, item);\n // Only a change of the slide in view is an event.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active]);\n\n const go = (index: number) => {\n const target = slides.current[Math.max(0, Math.min(items.length - 1, index))];\n if (!target) return;\n target.scrollIntoView({ behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\", block: \"start\" });\n target.querySelector<HTMLElement>(\"article\")?.focus({ preventScroll: true });\n };\n\n return (\n <section\n ref={root}\n aria-label={label}\n aria-roledescription=\"carousel\"\n className={cn(\"h-dvh snap-y snap-mandatory overflow-y-auto overscroll-contain bg-surface-0 [scrollbar-width:none]\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || event.key === \"PageDown\") {\n event.preventDefault();\n go(active + 1);\n } else if (event.key === \"ArrowUp\" || event.key === \"PageUp\") {\n event.preventDefault();\n go(active - 1);\n }\n }}\n {...props}\n >\n {items.map((item, index) => {\n const near = Math.abs(index - active) <= around;\n return (\n <div\n key={getId(item)}\n ref={(el) => {\n slides.current[index] = el;\n }}\n data-index={index}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${index + 1} / ${items.length}`}\n className=\"mx-auto flex h-dvh max-w-[calc(100dvh*9/16)] snap-start snap-always items-center\"\n >\n {near ? renderItem(item, { active: index === active, preload: index === active + 1 ? \"auto\" : index === active ? \"auto\" : \"none\", index }) : null}\n </div>\n );\n })}\n </section>\n );\n}\n"
607
+ },
608
+ {
609
+ "path": "vertical-video/vertical-video.tsx",
610
+ "content": "// Client (a video element, an observer, keys). A 9:16 clip filling its frame, with the action rail of a short-video feed on\n// the right (avatar + follow, like, comment, share, a slot for tip / unlock, more) and the caption block bottom-left. Every\n// word is a prop; counts are formatted with @krizaka/intl; everything over the picture reads on the invariant `scrim` /\n// `fg-on-media` tokens, so it is legible in both themes. Under reduced motion nothing autoplays: the poster and a play button.\nimport { formatCompact } from \"@krizaka/intl\";\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar/avatar\";\nimport { IconButton } from \"../button/button\";\nimport { cn } from \"../cn\";\nimport { VerifiedBadge } from \"../verified-badge/verified-badge\";\n\nexport interface VerticalVideoLabels {\n /** \"Play\" / \"Pause\" — the tap target over the picture. */\n play: string;\n pause: string;\n /** \"Like\" / \"Unlike\" (the button's name; the state is `aria-pressed`). */\n like: string;\n comment: string;\n share: string;\n more: string;\n /** \"Follow Elena\" — `{name}` is replaced by the creator's name. */\n follow: string;\n /** \"Following Elena\". */\n following: string;\n /** \"More\" / \"Less\" on a long caption. */\n expand: string;\n collapse: string;\n /** The progress bar's name (\"Progress\"). */\n progress: string;\n /** The rail's name (\"Actions\"). */\n actions: string;\n}\n\nexport const verticalVideoLabels: VerticalVideoLabels = {\n play: \"Play\",\n pause: \"Pause\",\n like: \"Like\",\n comment: \"Comments\",\n share: \"Share\",\n more: \"More\",\n follow: \"Follow {name}\",\n following: \"Following {name}\",\n expand: \"More\",\n collapse: \"Less\",\n progress: \"Progress\",\n actions: \"Actions\",\n};\n\nexport interface VerticalVideoCreator {\n name: string;\n /** Their portrait. */\n avatarSrc?: string;\n /** The seal's words (\"Verified\"): shows the seal beside the name. */\n verifiedLabel?: string;\n}\n\nexport interface VerticalVideoProps extends Omit<React.ComponentProps<\"article\">, \"title\" | \"children\"> {\n /** The video's accessible name (\"Video: Night shoot, part 2\"), passed translated. */\n title: string;\n /** The clip — absent, the poster alone shows (MP4 or WebM; an HLS address only in Safari — use the player of `@krizaka/ui/video-player` for HLS). */\n src?: string;\n /** The picture until the first frame, and under reduced motion. */\n poster?: string;\n /** Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`. */\n active?: boolean;\n /** The next slide's: `auto` loads it ahead, `none` waits. Default `metadata`. */\n preload?: \"none\" | \"metadata\" | \"auto\";\n /** Plays again at the end. Default `true`. */\n loop?: boolean;\n /** Starts muted (autoplay needs it). Default `true`. */\n muted?: boolean;\n /** Who made it: name, portrait, the seal's words. */\n creator: VerticalVideoCreator;\n /** A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote. */\n disclosure?: string;\n /** The caption: collapsed to two lines, expandable when long. */\n caption?: string;\n /** Tags under the caption (without `#`). */\n tags?: readonly string[];\n /** The sound or credit line (\"Original sound — Elena\"). */\n credit?: string;\n /** The like count under its button, compact (`12.4K`) in `locale`. */\n likes?: number;\n /** The comment count under its button. */\n comments?: number;\n /** The share count under its button. */\n shares?: number;\n /** Whether the viewer likes it: `aria-pressed` on the like button. */\n liked?: boolean;\n /** Whether the viewer follows the creator: the \"+\" on the avatar becomes a check. */\n following?: boolean;\n /** The viewer likes or unlikes (the button, `L`, a double tap). */\n onLike?: (liked: boolean) => void;\n /** Opens the comments; without it the button still shows. */\n onComment?: () => void;\n /** Opens the share sheet. */\n onShare?: () => void;\n /** The viewer follows or unfollows; without it the avatar has no \"+\". */\n onFollow?: (following: boolean) => void;\n /** The \"more\" button's action (report, hide); without it the button is not drawn. */\n onMore?: () => void;\n /** The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named). */\n tipSlot?: React.ReactNode;\n /** Top left: a chip over the picture (an auction's time left). */\n topStart?: React.ReactNode;\n /** Above the caption: a goal gauge, a challenge's progress. */\n aboveCaption?: React.ReactNode;\n /** The locale of the counts. Default `en`. */\n locale?: string;\n /** Every word, passed translated. */\n labels?: Partial<VerticalVideoLabels>;\n}\n\nconst glyph = (d: string) => (\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d={d} />\n </svg>\n);\nconst HEART = \"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z\";\nconst COMMENT = \"M4 5h16v11H9l-5 4z\";\nconst SHARE = \"M4 12v7h16v-7M12 3v12M7 8l5-5 5 5\";\nconst MORE = \"M5 12h.01M12 12h.01M19 12h.01\";\n\nconst useReducedMotion = () =>\n React.useSyncExternalStore(\n (notify) => {\n const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n query.addEventListener(\"change\", notify);\n return () => query.removeEventListener(\"change\", notify);\n },\n () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n () => false,\n );\n\n/** One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left, a progress hairline. */\nexport function VerticalVideo({\n title,\n src,\n poster,\n active = true,\n preload = \"metadata\",\n loop = true,\n muted = true,\n creator,\n disclosure,\n caption,\n tags = [],\n credit,\n likes,\n comments,\n shares,\n liked = false,\n following = false,\n onLike,\n onComment,\n onShare,\n onFollow,\n onMore,\n tipSlot,\n topStart,\n aboveCaption,\n locale = \"en\",\n labels: custom,\n className,\n onKeyDown,\n ...props\n}: VerticalVideoProps) {\n const labels = { ...verticalVideoLabels, ...custom };\n const reduce = useReducedMotion();\n const video = React.useRef<HTMLVideoElement>(null);\n const [playing, setPlaying] = React.useState(false);\n const [paused, setPaused] = React.useState(false); // the viewer's pause: it holds until they play again\n const [progress, setProgress] = React.useState(0);\n const [open, setOpen] = React.useState(false);\n const [heart, setHeart] = React.useState(0);\n\n const shouldPlay = active && !paused && !reduce;\n React.useEffect(() => {\n const el = video.current;\n if (!el) return;\n if (shouldPlay) void el.play().catch(() => undefined);\n else el.pause();\n }, [shouldPlay, src]);\n\n const press = () => {\n const el = video.current;\n if (!el) return;\n if (el.paused) {\n setPaused(false);\n void el.play().catch(() => undefined);\n } else {\n setPaused(true);\n el.pause();\n }\n };\n const like = () => onLike?.(!liked);\n const doubleTap = () => {\n if (!liked) like();\n setHeart((n) => n + 1); // a heart blooms over the picture (still under reduced motion: it shows and goes)\n };\n const count = (n?: number) => (n === undefined ? undefined : formatCompact(n, { locale }));\n const name = creator.name;\n\n return (\n <article\n aria-label={title}\n tabIndex={0}\n data-playing={playing ? \"\" : undefined}\n className={cn(\"relative isolate aspect-[9/16] w-full overflow-hidden bg-surface-0 text-fg-on-media focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\", className)}\n onKeyDown={(event) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.target !== event.currentTarget) return;\n if (event.key === \" \" || event.key.toLowerCase() === \"k\") {\n event.preventDefault();\n press();\n } else if (event.key.toLowerCase() === \"l\") like();\n }}\n {...props}\n >\n <video\n ref={video}\n src={src}\n poster={poster}\n muted={muted}\n loop={loop}\n playsInline\n preload={preload}\n className=\"absolute inset-0 -z-10 h-full w-full object-cover\"\n onPlay={() => setPlaying(true)}\n onPause={() => setPlaying(false)}\n onTimeUpdate={(event) => {\n const el = event.currentTarget;\n setProgress(el.duration ? el.currentTime / el.duration : 0);\n }}\n />\n {/* The tap target: a tap pauses or plays, a double tap likes. It is a button, so the keyboard has it too. */}\n <button type=\"button\" aria-label={playing ? labels.pause : labels.play} onClick={press} onDoubleClick={doubleTap} className=\"absolute inset-0 cursor-pointer bg-transparent focus-visible:outline-hidden\">\n {!playing && (\n <span aria-hidden className=\"absolute left-1/2 top-1/2 grid h-16 w-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-scrim backdrop-blur-md\">\n <svg viewBox=\"0 0 24 24\" className=\"h-7 w-7\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\" /></svg>\n </span>\n )}\n </button>\n {heart > 0 && (\n <span key={heart} aria-hidden className=\"pointer-events-none absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center text-accent motion-safe:animate-ping\">\n <svg viewBox=\"0 0 24 24\" className=\"h-20 w-20\" fill=\"currentColor\"><path d={HEART} /></svg>\n </span>\n )}\n <div aria-hidden className=\"pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-linear-to-t from-scrim to-transparent\" />\n {topStart && <div className=\"absolute left-3 top-3\">{topStart}</div>}\n\n <div role=\"group\" aria-label={labels.actions} className=\"absolute bottom-16 right-2 flex flex-col items-center gap-3\">\n <div className=\"relative\">\n <Avatar src={creator.avatarSrc} alt={name} fallback={name.slice(0, 1)} size=\"md\" className=\"ring-2 ring-white\" />\n {onFollow && (\n <IconButton\n label={format(following ? labels.following : labels.follow, name)}\n aria-pressed={following}\n size=\"icon\"\n variant={following ? \"secondary\" : \"primary\"}\n shape=\"pill\"\n onClick={() => onFollow(!following)}\n className=\"absolute -bottom-3 left-1/2 h-6 min-h-6 w-6 min-w-6 -translate-x-1/2 p-0\"\n >\n {glyph(following ? \"M5 12l5 5 9-10\" : \"M12 5v14M5 12h14\")}\n </IconButton>\n )}\n </div>\n <Rail label={labels.like} count={count(likes)} pressed={liked} onClick={like} tone={liked ? \"text-accent\" : undefined}>\n <svg viewBox=\"0 0 24 24\" className=\"h-6 w-6\" fill={liked ? \"currentColor\" : \"none\"} stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden><path d={HEART} /></svg>\n </Rail>\n <Rail label={labels.comment} count={count(comments)} onClick={onComment}>{glyph(COMMENT)}</Rail>\n <Rail label={labels.share} count={count(shares)} onClick={onShare}>{glyph(SHARE)}</Rail>\n {tipSlot}\n {onMore && <Rail label={labels.more} onClick={onMore}>{glyph(MORE)}</Rail>}\n </div>\n\n <div className=\"absolute bottom-4 left-3 right-20 flex flex-col gap-1.5\">\n {aboveCaption}\n <p className=\"flex items-center gap-1.5 text-sm font-bold\">\n {name}\n {creator.verifiedLabel && <VerifiedBadge label={creator.verifiedLabel} onMedia size=\"sm\" />}\n {disclosure && <span className=\"rounded-full bg-scrim px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-fg-on-media backdrop-blur-md\">{disclosure}</span>}\n </p>\n {caption && (\n <p className={cn(\"text-sm leading-snug\", !open && \"line-clamp-2\")}>\n {caption}\n {tags.length > 0 && <span className=\"font-semibold\"> {tags.map((tag) => `#${tag}`).join(\" \")}</span>}\n </p>\n )}\n {caption && caption.length > 90 && (\n <button type=\"button\" aria-expanded={open} onClick={() => setOpen((v) => !v)} className=\"w-fit rounded text-xs font-semibold underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\">\n {open ? labels.collapse : labels.expand}\n </button>\n )}\n {credit && <p className=\"text-xs opacity-90\">{credit}</p>}\n </div>\n\n <div role=\"progressbar\" aria-label={labels.progress} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)} className=\"absolute inset-x-0 bottom-0 h-1 bg-scrim\">\n <div className=\"h-full bg-white\" style={{ width: `${progress * 100}%` }} />\n </div>\n </article>\n );\n}\n\n/** \"Follow {name}\" → \"Follow Elena\". */\nconst format = (message: string, name: string) => message.replace(\"{name}\", name);\n\nfunction Rail({ label, count, pressed, onClick, tone, children }: { label: string; count?: string | undefined; pressed?: boolean; onClick?: (() => void) | undefined; tone?: string | undefined; children: React.ReactNode }) {\n return (\n <div className=\"flex flex-col items-center gap-0.5\">\n <IconButton label={label} aria-pressed={pressed} variant=\"ghost\" shape=\"pill\" onClick={onClick} className={cn(\"h-11 min-h-11 w-11 min-w-11 bg-scrim text-fg-on-media backdrop-blur-md hover:bg-scrim\", tone)}>\n {children}\n </IconButton>\n {count !== undefined && <span className=\"text-xs font-semibold tabular-nums\">{count}</span>}\n </div>\n );\n}\n"
611
+ }
612
+ ],
613
+ "dependencies": [
614
+ "@krizaka/intl@^0.2.0"
615
+ ],
616
+ "registryDependencies": [
617
+ "avatar",
618
+ "button",
619
+ "cn",
620
+ "verified-badge"
621
+ ],
622
+ "demo": {
623
+ "path": "examples/vertical-video/rail.tsx",
624
+ "content": "\"use client\";\n\nimport { Button } from \"@krizaka/ui/button\";\nimport { VerticalVideo } from \"@krizaka/ui/vertical-video\";\nimport { useState } from \"react\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site/people/ui\";\n\n// One short video (`src` and `poster` are yours: a clip of your own): the rail on the right (follow, like, comments, share, your own tip button), the caption bottom-left.\n// Tap pauses, double tap likes, Space pauses and L likes on the focused video.\nexport default function VerticalVideoRail({ src, poster = `${CDN}/camille-512.9573eaa0b8.webp` }: { src?: string; poster?: string } = {}) {\n const [liked, setLiked] = useState(false);\n const [following, setFollowing] = useState(false);\n return (\n <div className=\"w-64\">\n <VerticalVideo\n title=\"Video: Night shoot, part 2\"\n src={src}\n poster={poster}\n active={false}\n creator={{ name: \"Camille\", avatarSrc: `${CDN}/camille-256.dc2943029a.webp`, verifiedLabel: \"Verified\" }}\n caption=\"The rooftop, the rain, the last take at dawn.\"\n tags={[\"nightshoot\", \"montreal\"]}\n disclosure=\"AI-generated\"\n credit=\"Original sound — Camille\"\n likes={12400 + (liked ? 1 : 0)}\n comments={328}\n shares={95}\n liked={liked}\n following={following}\n onLike={setLiked}\n onFollow={setFollowing}\n onComment={() => undefined}\n onShare={() => undefined}\n tipSlot={<Button size=\"sm\" variant=\"primary\" shape=\"pill\">Tip</Button>}\n />\n </div>\n );\n}\n"
625
+ },
626
+ "props": [
627
+ {
628
+ "component": "ShortFeed",
629
+ "description": "A vertical snap-scrolling feed that plays the video in view, preloads the next and pauses the rest.",
630
+ "props": [
631
+ {
632
+ "name": "getId",
633
+ "type": "(item: T) => string",
634
+ "default": null,
635
+ "description": "A stable key for an item.",
636
+ "required": true
637
+ },
638
+ {
639
+ "name": "items",
640
+ "type": "readonly T[]",
641
+ "default": null,
642
+ "description": "The videos, in order.",
643
+ "required": true
644
+ },
645
+ {
646
+ "name": "label",
647
+ "type": "string",
648
+ "default": null,
649
+ "description": "The feed's accessible name (\"Videos\"), passed translated.",
650
+ "required": true
651
+ },
652
+ {
653
+ "name": "renderItem",
654
+ "type": "(item: T, state: { active: boolean; preload: \"none\" | \"auto\" | \"metadata\"; index: number; }) => ReactNode",
655
+ "default": null,
656
+ "description": "One slide: `active` is true for the one in view, `preload` for the next, `near` false for the ones kept empty.",
657
+ "required": true
658
+ },
659
+ {
660
+ "name": "onActiveChange",
661
+ "type": "((index: number, item: T) => void)",
662
+ "default": null,
663
+ "description": "Called when the slide in view changes.",
664
+ "required": false
665
+ },
666
+ {
667
+ "name": "window",
668
+ "type": "number",
669
+ "default": "1",
670
+ "description": "The slides mounted around the one in view (before and after). Default 1; the others keep their height empty.",
671
+ "required": false
672
+ }
673
+ ]
674
+ },
675
+ {
676
+ "component": "VerticalVideo",
677
+ "description": "One short video: 9:16, the rail of actions on the right, the creator's caption bottom-left, a progress hairline.",
678
+ "props": [
679
+ {
680
+ "name": "creator",
681
+ "type": "VerticalVideoCreator",
682
+ "default": null,
683
+ "description": "Who made it: name, portrait, the seal's words.",
684
+ "required": true
685
+ },
686
+ {
687
+ "name": "title",
688
+ "type": "string",
689
+ "default": null,
690
+ "description": "The video's accessible name (\"Video: Night shoot, part 2\"), passed translated.",
691
+ "required": true
692
+ },
693
+ {
694
+ "name": "aboveCaption",
695
+ "type": "ReactNode",
696
+ "default": null,
697
+ "description": "Above the caption: a goal gauge, a challenge's progress.",
698
+ "required": false
699
+ },
700
+ {
701
+ "name": "active",
702
+ "type": "boolean",
703
+ "default": "true",
704
+ "description": "Plays while `true`; a `ShortFeed` passes it for the slide in view. Default `true`.",
705
+ "required": false
706
+ },
707
+ {
708
+ "name": "caption",
709
+ "type": "string",
710
+ "default": null,
711
+ "description": "The caption: collapsed to two lines, expandable when long.",
712
+ "required": false
713
+ },
714
+ {
715
+ "name": "comments",
716
+ "type": "number",
717
+ "default": null,
718
+ "description": "The comment count under its button.",
719
+ "required": false
720
+ },
721
+ {
722
+ "name": "credit",
723
+ "type": "string",
724
+ "default": null,
725
+ "description": "The sound or credit line (\"Original sound — Elena\").",
726
+ "required": false
727
+ },
728
+ {
729
+ "name": "disclosure",
730
+ "type": "string",
731
+ "default": null,
732
+ "description": "A pill beside the name: \"AI-GENERATED\" — said on the picture, never only in a page's footnote.",
733
+ "required": false
734
+ },
735
+ {
736
+ "name": "following",
737
+ "type": "boolean",
738
+ "default": "false",
739
+ "description": "Whether the viewer follows the creator: the \"+\" on the avatar becomes a check.",
740
+ "required": false
741
+ },
742
+ {
743
+ "name": "labels",
744
+ "type": "Partial<VerticalVideoLabels>",
745
+ "default": null,
746
+ "description": "Every word, passed translated.",
747
+ "required": false
748
+ },
749
+ {
750
+ "name": "liked",
751
+ "type": "boolean",
752
+ "default": "false",
753
+ "description": "Whether the viewer likes it: `aria-pressed` on the like button.",
754
+ "required": false
755
+ },
756
+ {
757
+ "name": "likes",
758
+ "type": "number",
759
+ "default": null,
760
+ "description": "The like count under its button, compact (`12.4K`) in `locale`.",
761
+ "required": false
762
+ },
763
+ {
764
+ "name": "locale",
765
+ "type": "string",
766
+ "default": "en",
767
+ "description": "The locale of the counts. Default `en`.",
768
+ "required": false
769
+ },
770
+ {
771
+ "name": "loop",
772
+ "type": "boolean",
773
+ "default": "true",
774
+ "description": "Plays again at the end. Default `true`.",
775
+ "required": false
776
+ },
777
+ {
778
+ "name": "muted",
779
+ "type": "boolean",
780
+ "default": "true",
781
+ "description": "Starts muted (autoplay needs it). Default `true`.",
782
+ "required": false
783
+ },
784
+ {
785
+ "name": "onComment",
786
+ "type": "(() => void)",
787
+ "default": null,
788
+ "description": "Opens the comments; without it the button still shows.",
789
+ "required": false
790
+ },
791
+ {
792
+ "name": "onFollow",
793
+ "type": "((following: boolean) => void)",
794
+ "default": null,
795
+ "description": "The viewer follows or unfollows; without it the avatar has no \"+\".",
796
+ "required": false
797
+ },
798
+ {
799
+ "name": "onLike",
800
+ "type": "((liked: boolean) => void)",
801
+ "default": null,
802
+ "description": "The viewer likes or unlikes (the button, `L`, a double tap).",
803
+ "required": false
804
+ },
805
+ {
806
+ "name": "onMore",
807
+ "type": "(() => void)",
808
+ "default": null,
809
+ "description": "The \"more\" button's action (report, hide); without it the button is not drawn.",
810
+ "required": false
811
+ },
812
+ {
813
+ "name": "onShare",
814
+ "type": "(() => void)",
815
+ "default": null,
816
+ "description": "Opens the share sheet.",
817
+ "required": false
818
+ },
819
+ {
820
+ "name": "poster",
821
+ "type": "string",
822
+ "default": null,
823
+ "description": "The picture until the first frame, and under reduced motion.",
824
+ "required": false
825
+ },
826
+ {
827
+ "name": "preload",
828
+ "type": "\"none\" | \"auto\" | \"metadata\"",
829
+ "default": "metadata",
830
+ "description": "The next slide's: `auto` loads it ahead, `none` waits. Default `metadata`.",
831
+ "required": false
832
+ },
833
+ {
834
+ "name": "shares",
835
+ "type": "number",
836
+ "default": null,
837
+ "description": "The share count under its button.",
838
+ "required": false
839
+ },
840
+ {
841
+ "name": "src",
842
+ "type": "string",
843
+ "default": null,
844
+ "description": "The clip — absent, the poster alone shows (MP4 or WebM; an HLS address only in Safari — use the player of `@krizaka/ui/video-player` for HLS).",
845
+ "required": false
846
+ },
847
+ {
848
+ "name": "tags",
849
+ "type": "readonly string[]",
850
+ "default": "[]",
851
+ "description": "Tags under the caption (without `#`).",
852
+ "required": false
853
+ },
854
+ {
855
+ "name": "tipSlot",
856
+ "type": "ReactNode",
857
+ "default": null,
858
+ "description": "The product's own action on the rail: a tip, an unlock — a button of yours (≥ 44 px, named).",
859
+ "required": false
860
+ },
861
+ {
862
+ "name": "topStart",
863
+ "type": "ReactNode",
864
+ "default": null,
865
+ "description": "Top left: a chip over the picture (an auction's time left).",
866
+ "required": false
867
+ }
868
+ ]
869
+ }
870
+ ]
871
+ }