@krizaka/ui 3.17.0 → 3.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/media-grid.d.ts +146 -0
  2. package/dist/media-grid.js +262 -0
  3. package/dist/media-grid.js.map +1 -0
  4. package/package.json +23 -4
  5. package/registry/achievement.json +1 -1
  6. package/registry/action-bar.json +1 -1
  7. package/registry/activity-feed.json +1 -1
  8. package/registry/alert.json +1 -1
  9. package/registry/app-shell.json +1 -1
  10. package/registry/argument-card.json +1 -1
  11. package/registry/audio-player.json +1 -1
  12. package/registry/auth-layout.json +1 -1
  13. package/registry/avatar.json +1 -1
  14. package/registry/badge.json +1 -1
  15. package/registry/button.json +1 -1
  16. package/registry/card-grid.json +1 -1
  17. package/registry/card.json +1 -1
  18. package/registry/catalogue-page.json +1 -1
  19. package/registry/changelog-page.json +1 -1
  20. package/registry/chat.json +1 -1
  21. package/registry/checkbox.json +1 -1
  22. package/registry/chip-link.json +1 -1
  23. package/registry/chip.json +1 -1
  24. package/registry/choice-grid.json +1 -1
  25. package/registry/code-block.json +1 -1
  26. package/registry/combobox.json +1 -1
  27. package/registry/command.json +1 -1
  28. package/registry/component-page.json +1 -1
  29. package/registry/confirm-button.json +1 -1
  30. package/registry/contact-page.json +1 -1
  31. package/registry/continue-prompt.json +1 -1
  32. package/registry/countdown.json +1 -1
  33. package/registry/cta-panel.json +1 -1
  34. package/registry/dashboard-layout.json +1 -1
  35. package/registry/data-table.json +1 -1
  36. package/registry/date-picker.json +1 -1
  37. package/registry/decision-list.json +1 -1
  38. package/registry/description-list.json +1 -1
  39. package/registry/detail-layout.json +1 -1
  40. package/registry/dialog.json +1 -1
  41. package/registry/docs-article-page.json +1 -1
  42. package/registry/docs-hub-page.json +1 -1
  43. package/registry/docs-layout.json +1 -1
  44. package/registry/document-viewer.json +1 -1
  45. package/registry/dropdown-menu.json +1 -1
  46. package/registry/empty-state.json +1 -1
  47. package/registry/error-page.json +1 -1
  48. package/registry/examples/media-grid/feed.d.ts +4 -0
  49. package/registry/examples/media-grid/feed.js +32 -0
  50. package/registry/examples/media-grid/feed.tsx +31 -0
  51. package/registry/examples/media-grid/uniform.d.ts +4 -0
  52. package/registry/examples/media-grid/uniform.js +29 -0
  53. package/registry/examples/media-grid/uniform.tsx +26 -0
  54. package/registry/field.json +1 -1
  55. package/registry/file-upload.json +1 -1
  56. package/registry/filter-bar.json +1 -1
  57. package/registry/form.json +1 -1
  58. package/registry/gallery-page.json +1 -1
  59. package/registry/glow-card.json +1 -1
  60. package/registry/goal-gauge.json +1 -1
  61. package/registry/how-it-works-page.json +1 -1
  62. package/registry/image-cropper.json +1 -1
  63. package/registry/index.json +24 -4
  64. package/registry/iso.json +1 -1
  65. package/registry/kanban.json +1 -1
  66. package/registry/kbd.json +1 -1
  67. package/registry/landing-page.json +1 -1
  68. package/registry/landscape.json +1 -1
  69. package/registry/legal-page.json +1 -1
  70. package/registry/link-card.json +1 -1
  71. package/registry/list.json +1 -1
  72. package/registry/loader.json +1 -1
  73. package/registry/longform-page.json +1 -1
  74. package/registry/map.json +2 -2
  75. package/registry/markdown.json +1 -1
  76. package/registry/marketing-layout.json +1 -1
  77. package/registry/marquee.json +1 -1
  78. package/registry/mascot-quote.json +1 -1
  79. package/registry/mascot-state.json +1 -1
  80. package/registry/mascots.json +1 -1
  81. package/registry/match-dialog.json +1 -1
  82. package/registry/media-capture.json +1 -1
  83. package/registry/media-grid.json +618 -0
  84. package/registry/meter.json +1 -1
  85. package/registry/nav-bar.json +2 -2
  86. package/registry/number-ticker.json +1 -1
  87. package/registry/page-header.json +1 -1
  88. package/registry/page-hero.json +1 -1
  89. package/registry/page.json +1 -1
  90. package/registry/pagination.json +1 -1
  91. package/registry/photo-grid.json +1 -1
  92. package/registry/pointer-light.json +1 -1
  93. package/registry/popover.json +1 -1
  94. package/registry/pricing-page.json +1 -1
  95. package/registry/product-page.json +1 -1
  96. package/registry/profile-card.json +1 -1
  97. package/registry/progress.json +1 -1
  98. package/registry/promise-card.json +1 -1
  99. package/registry/radio-group.json +1 -1
  100. package/registry/recipes.json +1 -1
  101. package/registry/scroll-stage.json +1 -1
  102. package/registry/search-field.json +1 -1
  103. package/registry/section-backdrop.json +1 -1
  104. package/registry/separator.json +1 -1
  105. package/registry/settings-layout.json +1 -1
  106. package/registry/showcase.json +1 -1
  107. package/registry/site-footer.json +2 -2
  108. package/registry/sites.json +1 -1
  109. package/registry/skeleton.json +1 -1
  110. package/registry/skip-link.json +1 -1
  111. package/registry/slider.json +1 -1
  112. package/registry/slot.json +1 -1
  113. package/registry/spinner.json +1 -1
  114. package/registry/split-view.json +1 -1
  115. package/registry/stage-banner.json +1 -1
  116. package/registry/stat.json +1 -1
  117. package/registry/status-page.json +1 -1
  118. package/registry/step-progress.json +1 -1
  119. package/registry/stepper.json +1 -1
  120. package/registry/swipe-deck.json +1 -1
  121. package/registry/switch.json +1 -1
  122. package/registry/tabs.json +1 -1
  123. package/registry/tag-input.json +1 -1
  124. package/registry/tag-selector.json +1 -1
  125. package/registry/terminal-replay.json +1 -1
  126. package/registry/theme.json +1 -1
  127. package/registry/toast.json +1 -1
  128. package/registry/toc.json +1 -1
  129. package/registry/tooltip.json +1 -1
  130. package/registry/tree-view.json +1 -1
  131. package/registry/verified-badge.json +1 -1
  132. package/registry/vertical-video.json +1 -1
  133. package/registry/video-player.json +1 -1
  134. package/registry/video-trimmer.json +1 -1
  135. package/registry/virtual.json +1 -1
@@ -0,0 +1,618 @@
1
+ {
2
+ "name": "media-grid",
3
+ "type": "primitive",
4
+ "title": "Media grid",
5
+ "summary": "A feed of videos in mixed orientations that never staggers: `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.",
6
+ "description": "A feed of videos in mixed orientations that never staggers: `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.",
7
+ "status": "beta",
8
+ "deprecated": null,
9
+ "category": "media",
10
+ "platforms": "web",
11
+ "whenToUse": [
12
+ "A home, an explore page or a profile's Videos tab where creators publish both vertical and horizontal video.",
13
+ "A gallery whose items come from different sources (`uniform`: everything in one frame, the odd one drawn whole over a blurred copy)."
14
+ ],
15
+ "whenNotToUse": [
16
+ {
17
+ "when": "For a feed that plays one video per screen.",
18
+ "use": "vertical-video"
19
+ },
20
+ {
21
+ "when": "For still photos the owner reorders.",
22
+ "use": "photo-grid"
23
+ }
24
+ ],
25
+ "bestPractices": [
26
+ "Leave `mode` on `split` for a feed: a 9:16 card in a 16:9 grid is what makes a row tower over its neighbours.",
27
+ "Give `orientationOf` the source's width and height, not the card's: a clip of the other shape is drawn with `fit=\"contain\"`, never cropped to a face.",
28
+ "Put what is the product's own in the slots (`badges`, `action`); badges sit on `scrim` and read in both themes.",
29
+ "Pass every word translated: the shelf's name, its arrows and “See all”."
30
+ ],
31
+ "accessibility": {
32
+ "keyboard": [
33
+ {
34
+ "keys": "Tab",
35
+ "action": "Each card is one link (its title); the creator and the action are their own stops; the shelf takes the focus to scroll with the arrow keys."
36
+ },
37
+ {
38
+ "keys": "Arrow Left / Arrow Right",
39
+ "action": "Scrolls the focused shelf."
40
+ }
41
+ ],
42
+ "notes": [
43
+ "The shelf is a labelled section whose row is a list; the arrows are named buttons shown with a mouse, hidden on touch (swipe).",
44
+ "The card's whole surface is the title's link (a stretched link): no nested interactive element.",
45
+ "The animated preview plays on hover or focus only."
46
+ ]
47
+ },
48
+ "related": [
49
+ "vertical-video",
50
+ "card",
51
+ "photo-grid"
52
+ ],
53
+ "web": {
54
+ "entry": "@krizaka/ui/media-grid",
55
+ "import": "import { MediaGrid, ShortsShelf, VideoCard } from \"@krizaka/ui/media-grid\";",
56
+ "props": [
57
+ {
58
+ "component": "MediaGrid",
59
+ "description": "A media grid that never mixes heights badly: a shelf for the 9:16, a uniform grid for the rest.",
60
+ "props": [
61
+ {
62
+ "name": "getId",
63
+ "type": "(item: T) => string",
64
+ "default": null,
65
+ "description": "A stable key for an item.",
66
+ "required": true
67
+ },
68
+ {
69
+ "name": "items",
70
+ "type": "readonly T[]",
71
+ "default": null,
72
+ "description": "The videos, in order.",
73
+ "required": true
74
+ },
75
+ {
76
+ "name": "orientationOf",
77
+ "type": "(item: T) => VideoCardAspect",
78
+ "default": null,
79
+ "description": "The source's orientation (from its width and height).",
80
+ "required": true
81
+ },
82
+ {
83
+ "name": "renderItem",
84
+ "type": "(item: T, state: MediaGridState) => ReactNode",
85
+ "default": null,
86
+ "description": "One card, drawn in the frame the grid gives (`state`): use `VideoCard`.",
87
+ "required": true
88
+ },
89
+ {
90
+ "name": "shelf",
91
+ "type": "Pick<ShortsShelfProps<unknown>, \"title\" | \"label\" | \"seeAll\" | \"previousLabel\" | \"nextLabel\">",
92
+ "default": null,
93
+ "description": "The shelf of `split`: its name, \"See all\" and arrows, passed translated.",
94
+ "required": true
95
+ },
96
+ {
97
+ "name": "frame",
98
+ "type": "VideoCardAspect",
99
+ "default": "landscape",
100
+ "description": "The frame of `uniform`.",
101
+ "required": false
102
+ },
103
+ {
104
+ "name": "layout",
105
+ "type": "\"grid\" | \"list\"",
106
+ "default": "grid",
107
+ "description": "`grid` or `list` (one row per video).",
108
+ "required": false
109
+ },
110
+ {
111
+ "name": "mode",
112
+ "type": "\"split\" | \"uniform\"",
113
+ "default": "split",
114
+ "description": "`split`: verticals on the shelf, the others in the grid · `uniform`: all in one frame (`frame`).",
115
+ "required": false
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ "component": "ShortsShelf",
121
+ "description": "A Shorts shelf: equal-height 9:16 cards in a snap-scrolling row.",
122
+ "props": [
123
+ {
124
+ "name": "getId",
125
+ "type": "(item: T) => string",
126
+ "default": null,
127
+ "description": "A stable key for an item.",
128
+ "required": true
129
+ },
130
+ {
131
+ "name": "items",
132
+ "type": "readonly T[]",
133
+ "default": null,
134
+ "description": "The clips, in order.",
135
+ "required": true
136
+ },
137
+ {
138
+ "name": "label",
139
+ "type": "string",
140
+ "default": null,
141
+ "description": "The shelf's accessible name (\"Shorts\"), passed translated; shown as its heading unless `title` is set.",
142
+ "required": true
143
+ },
144
+ {
145
+ "name": "nextLabel",
146
+ "type": "string",
147
+ "default": null,
148
+ "description": "The next arrow's name.",
149
+ "required": true
150
+ },
151
+ {
152
+ "name": "previousLabel",
153
+ "type": "string",
154
+ "default": null,
155
+ "description": "The arrows' names, passed translated.",
156
+ "required": true
157
+ },
158
+ {
159
+ "name": "renderItem",
160
+ "type": "(item: T, index: number) => ReactNode",
161
+ "default": null,
162
+ "description": "One card: use `VideoCard aspect=\"portrait\"`.",
163
+ "required": true
164
+ },
165
+ {
166
+ "name": "seeAll",
167
+ "type": "{ href: string; label: string; linkAs?: LinkComponent; }",
168
+ "default": null,
169
+ "description": "Opens the vertical feed.",
170
+ "required": false
171
+ },
172
+ {
173
+ "name": "title",
174
+ "type": "ReactNode",
175
+ "default": null,
176
+ "description": "A heading different from the name.",
177
+ "required": false
178
+ }
179
+ ]
180
+ },
181
+ {
182
+ "component": "VideoCard",
183
+ "description": "A video card whose frame, body and footer have one height per aspect, so a grid row or a shelf never staggers.",
184
+ "props": [
185
+ {
186
+ "name": "href",
187
+ "type": "string",
188
+ "default": null,
189
+ "description": "Where the card opens.",
190
+ "required": true
191
+ },
192
+ {
193
+ "name": "title",
194
+ "type": "string",
195
+ "default": null,
196
+ "description": "The title; it names the card and is its link.",
197
+ "required": true
198
+ },
199
+ {
200
+ "name": "action",
201
+ "type": "ReactNode",
202
+ "default": null,
203
+ "description": "One quick action at the end of the footer (a tip, a save). Its clicks must not follow the card's link.",
204
+ "required": false
205
+ },
206
+ {
207
+ "name": "aspect",
208
+ "type": "VideoCardAspect",
209
+ "default": "landscape",
210
+ "description": "The frame: `landscape` (16:9) or `portrait` (9:16).",
211
+ "required": false
212
+ },
213
+ {
214
+ "name": "badges",
215
+ "type": "ReactNode",
216
+ "default": null,
217
+ "description": "Badges at the top-left of the picture (Free, a price, 18+). Put them on `scrim` so they read in both themes.",
218
+ "required": false
219
+ },
220
+ {
221
+ "name": "blurred",
222
+ "type": "boolean",
223
+ "default": "false",
224
+ "description": "A sensitive picture: blurred and scaled so nothing shows (put the reveal button in `overlay`).",
225
+ "required": false
226
+ },
227
+ {
228
+ "name": "creator",
229
+ "type": "VideoCardCreator",
230
+ "default": null,
231
+ "description": "Who made it: name, photo, profile.",
232
+ "required": false
233
+ },
234
+ {
235
+ "name": "duration",
236
+ "type": "string",
237
+ "default": null,
238
+ "description": "The length, already formatted (`3:42`).",
239
+ "required": false
240
+ },
241
+ {
242
+ "name": "fallback",
243
+ "type": "ReactNode",
244
+ "default": null,
245
+ "description": "Shown instead of the picture while there is none.",
246
+ "required": false
247
+ },
248
+ {
249
+ "name": "fit",
250
+ "type": "\"cover\" | \"contain\"",
251
+ "default": "cover",
252
+ "description": "`cover` fills the frame; `contain` draws the whole picture over a blurred copy of itself (a source of the other\norientation: no cropped face, no black bar).",
253
+ "required": false
254
+ },
255
+ {
256
+ "name": "layout",
257
+ "type": "\"row\" | \"stack\"",
258
+ "default": "stack",
259
+ "description": "`stack` (picture over text) or `row` (picture beside the text: the list mode of a section).",
260
+ "required": false
261
+ },
262
+ {
263
+ "name": "linkAs",
264
+ "type": "LinkComponent",
265
+ "default": null,
266
+ "description": "The app's router link (Next's `Link`).",
267
+ "required": false
268
+ },
269
+ {
270
+ "name": "loading",
271
+ "type": "\"eager\" | \"lazy\"",
272
+ "default": "lazy",
273
+ "description": "`eager` for the first cards of a page.",
274
+ "required": false
275
+ },
276
+ {
277
+ "name": "meta",
278
+ "type": "ReactNode",
279
+ "default": null,
280
+ "description": "What is counted (`12.4K views`), already formatted.",
281
+ "required": false
282
+ },
283
+ {
284
+ "name": "overlay",
285
+ "type": "ReactNode",
286
+ "default": null,
287
+ "description": "Over the whole picture, above the card's link (a veil, a reveal button).",
288
+ "required": false
289
+ },
290
+ {
291
+ "name": "poster",
292
+ "type": "string | null",
293
+ "default": null,
294
+ "description": "The picture.",
295
+ "required": false
296
+ },
297
+ {
298
+ "name": "previewSrc",
299
+ "type": "string | null",
300
+ "default": null,
301
+ "description": "An animated picture (WebP) shown while the card is hovered or focused.",
302
+ "required": false
303
+ }
304
+ ]
305
+ }
306
+ ],
307
+ "examples": [
308
+ {
309
+ "name": "feed",
310
+ "title": "A feed",
311
+ "description": "Three horizontal clips in the grid, three vertical on the shelf: every card of a row has one height.",
312
+ "path": "examples/media-grid/feed.tsx",
313
+ "module": "@krizaka/ui/registry/examples/media-grid/feed",
314
+ "code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { MediaGrid, VideoCard } from \"@krizaka/ui/media-grid\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site\";\nconst WIDE = `${CDN}/products/orazaka/gallery`;\nconst TALL = `${CDN}/docs/orochia/mobile`;\nconst CLIPS = [\n { id: \"tea\", title: \"Pouring the mint tea, slow\", poster: `${WIDE}/tea-pour.d69c3a5700.webp`, tall: false },\n { id: \"story\", title: \"Backstage, a minute before\", poster: `${TALL}/clip-feed-stories-poster.fd7835fa74.webp`, tall: true },\n { id: \"snow\", title: \"First snow on rue Saint-Denis\", poster: `${WIDE}/mtl-snow.e3ecb20e82.webp`, tall: false },\n { id: \"watch\", title: \"The unlock in one tap\", poster: `${TALL}/clip-watch-poster.9fc5d19c39.webp`, tall: true },\n { id: \"desert\", title: \"Desert camp at blue hour\", poster: `${WIDE}/desert-camp.78c6c0d26c.webp`, tall: false },\n { id: \"thanks\", title: \"Three ways to say thanks\", poster: `${TALL}/clip-unlock-poster.57e13fee5f.webp`, tall: true },\n];\n\n// 'split' (the feed default): vertical clips on a Shorts shelf, the others in a 16:9 grid whose cards share one height.\nexport default function MediaGridFeed() {\n return (\n <MediaGrid\n items={CLIPS}\n getId={(c) => c.id}\n orientationOf={(c) => (c.tall ? \"portrait\" : \"landscape\")}\n shelf={{ label: \"Shorts\", previousLabel: \"Previous shorts\", nextLabel: \"Next shorts\", seeAll: { href: \"#shorts\", label: \"See all\" } }}\n renderItem={(c, { aspect, layout }) => (\n <VideoCard href={`#${c.id}`} title={c.title} aspect={aspect} layout={layout} poster={c.poster} duration=\"0:12\" meta=\"12.4K views\" creator={{ name: \"Camille\" }} badges={<Badge tone=\"success\" size=\"sm\" className=\"bg-scrim\">Free</Badge>} />\n )}\n />\n );\n}\n"
315
+ },
316
+ {
317
+ "name": "uniform",
318
+ "title": "One frame",
319
+ "description": "`mode=\"uniform\"`: vertical clips drawn whole over a blurred copy inside 16:9 cards.",
320
+ "path": "examples/media-grid/uniform.tsx",
321
+ "module": "@krizaka/ui/registry/examples/media-grid/uniform",
322
+ "code": "\"use client\";\n\nimport { MediaGrid, VideoCard } from \"@krizaka/ui/media-grid\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site\";\nconst CLIPS = [\n { id: \"tea\", title: \"Pouring the mint tea, slow\", poster: `${CDN}/products/orazaka/gallery/tea-pour.d69c3a5700.webp`, portrait: false },\n { id: \"story\", title: \"Backstage, a minute before\", poster: `${CDN}/docs/orochia/mobile/clip-feed-stories-poster.fd7835fa74.webp`, portrait: true },\n { id: \"snow\", title: \"First snow on rue Saint-Denis\", poster: `${CDN}/products/orazaka/gallery/mtl-snow.e3ecb20e82.webp`, portrait: false },\n { id: \"watch\", title: \"The unlock in one tap\", poster: `${CDN}/docs/orochia/mobile/clip-watch-poster.9fc5d19c39.webp`, portrait: true },\n];\n\n// 'uniform': one frame for everyone; a clip of the other orientation is drawn whole over a blurred copy of itself.\nexport default function MediaGridUniform() {\n return (\n <MediaGrid\n mode=\"uniform\"\n frame=\"landscape\"\n items={CLIPS}\n getId={(c) => c.id}\n orientationOf={(c) => (c.portrait ? \"portrait\" : \"landscape\")}\n shelf={{ label: \"Shorts\", previousLabel: \"Previous\", nextLabel: \"Next\" }}\n renderItem={(c, { aspect, layout, mismatch }) => <VideoCard href={`#${c.id}`} title={c.title} aspect={aspect} layout={layout} fit={mismatch ? \"contain\" : \"cover\"} poster={c.poster} meta=\"2.4K views\" />}\n />\n );\n}\n"
323
+ }
324
+ ]
325
+ },
326
+ "native": null,
327
+ "files": [
328
+ {
329
+ "path": "block-link.tsx",
330
+ "content": "// The links of the page blocks (PageHero's back link, LinkCard, ArgumentCard, ActionBar): an internal path renders\n// through the app's router link (`linkAs`, e.g. Next's `Link`), anything else is an `<a>` that opens in a new tab.\n// Shared by the block entries; never exported on its own.\nimport type * as React from \"react\";\n\n/** The app's link component (Next's `Link`, React Router's `Link`…): receives `href`, `className` and the children. */\nexport type LinkComponent = React.ElementType<{ href: string; className?: string; children?: React.ReactNode; \"aria-label\"?: string; \"aria-describedby\"?: string }>;\n\n/** An address that leaves the app: an absolute URL or a protocol (`mailto:`). */\nexport const isExternal = (href: string) => /^[a-z][a-z0-9+.-]*:/i.test(href);\n\nexport function BlockLink({\n href,\n linkAs: Link = \"a\",\n external = isExternal(href),\n rel,\n ...props\n}: Omit<React.ComponentProps<\"a\">, \"href\"> & { href: string; linkAs?: LinkComponent; external?: boolean }) {\n if (external) return <a href={href} target=\"_blank\" rel={rel ?? \"noopener noreferrer\"} {...props} />;\n const Comp = Link as React.ElementType;\n return <Comp href={href} {...props} />;\n}\n"
331
+ },
332
+ {
333
+ "path": "media-grid/index.ts",
334
+ "content": "/** @krizaka/ui/media-grid — `MediaGrid` (a shelf for 9:16, a uniform grid for the rest), `ShortsShelf`, `VideoCard` (aspect-aware, one body height). Client. */\nexport { MediaGrid, type MediaGridProps, type MediaGridState } from \"./media-grid\";\nexport { ShortsShelf, type ShortsShelfProps } from \"./shorts-shelf\";\nexport { VideoCard, type VideoCardAspect, type VideoCardCreator, type VideoCardProps } from \"./video-card\";\n"
335
+ },
336
+ {
337
+ "path": "media-grid/media-grid.tsx",
338
+ "content": "// Client (it renders the shelf). Mixed orientations without a broken grid. `split` (the feed default) sends the 9:16\n// clips to a `ShortsShelf` and lays the rest in a uniform 16:9 grid; `uniform` puts everything in one grid of one frame\n// (a source of the other orientation is told to `contain`, never cropped to a face). `layout=\"list\"` is one column.\nimport * as React from \"react\";\n\nimport { cn } from \"../cn\";\nimport { ShortsShelf, type ShortsShelfProps } from \"./shorts-shelf\";\nimport type { VideoCardAspect } from \"./video-card\";\n\nexport interface MediaGridState {\n /** The frame the card is drawn in (`aspect` of `VideoCard`). */\n aspect: VideoCardAspect;\n /** The card's `layout`. */\n layout: \"stack\" | \"row\";\n /** The source's orientation differs from the frame: pass `fit=\"contain\"`. */\n mismatch: boolean;\n}\n\nexport interface MediaGridProps<T> extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n /** The videos, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** The source's orientation (from its width and height). */\n orientationOf: (item: T) => VideoCardAspect;\n /** One card, drawn in the frame the grid gives (`state`): use `VideoCard`. */\n renderItem: (item: T, state: MediaGridState) => React.ReactNode;\n /**\n * `split`: verticals on the shelf, the others in the grid · `uniform`: all in one frame (`frame`).\n * @default \"split\"\n */\n mode?: \"split\" | \"uniform\";\n /** The frame of `uniform`. @default \"landscape\" */\n frame?: VideoCardAspect;\n /** `grid` or `list` (one row per video). @default \"grid\" */\n layout?: \"grid\" | \"list\";\n /** The shelf of `split`: its name, \"See all\" and arrows, passed translated. */\n shelf: Pick<ShortsShelfProps<unknown>, \"label\" | \"title\" | \"seeAll\" | \"previousLabel\" | \"nextLabel\">;\n}\n\nconst GRID = {\n landscape: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4\",\n portrait: \"grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6\",\n} as const;\n\n/** A media grid that never mixes heights badly: a shelf for the 9:16, a uniform grid for the rest. */\nexport function MediaGrid<T>({ items, getId, orientationOf, renderItem, mode = \"split\", frame = \"landscape\", layout = \"grid\", shelf, className, ...props }: MediaGridProps<T>) {\n const row = layout === \"list\";\n const split = mode === \"split\";\n const verticals = split ? items.filter((i) => orientationOf(i) === \"portrait\") : [];\n const rest = split ? items.filter((i) => orientationOf(i) !== \"portrait\") : items;\n const gridFrame: VideoCardAspect = split ? \"landscape\" : frame;\n const state = (item: T, aspect: VideoCardAspect): MediaGridState => ({ aspect, layout: row ? \"row\" : \"stack\", mismatch: orientationOf(item) !== aspect });\n return (\n <div data-media-grid={mode} className={cn(\"flex min-w-0 flex-col gap-8\", className)} {...props}>\n {verticals.length > 0 && <ShortsShelf {...shelf} items={verticals} getId={getId} renderItem={(item) => renderItem(item, { ...state(item, \"portrait\"), layout: \"stack\" })} />}\n {rest.length > 0 && (\n <ul data-grid-frame={gridFrame} className={cn(\"m-0 grid list-none auto-rows-fr gap-4 p-0\", row ? \"grid-cols-1\" : GRID[gridFrame])}>\n {rest.map((item) => (\n <li key={getId(item)} className=\"min-w-0\">\n {renderItem(item, state(item, gridFrame))}\n </li>\n ))}\n </ul>\n )}\n </div>\n );\n}\n"
339
+ },
340
+ {
341
+ "path": "media-grid/meta.ts",
342
+ "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Media grid\",\n summary: \"A feed of videos in mixed orientations that never staggers: `MediaGrid` routes the 9:16 clips to a `ShortsShelf` (equal-height cards, snap scroll, arrows, “See all”) and lays the others in a uniform 16:9 grid; `VideoCard` is aspect-aware and gives every card the same body height so rows align.\",\n status: \"beta\",\n category: \"media\",\n platforms: \"web\",\n whenToUse: [\n \"A home, an explore page or a profile's Videos tab where creators publish both vertical and horizontal video.\",\n \"A gallery whose items come from different sources (`uniform`: everything in one frame, the odd one drawn whole over a blurred copy).\",\n ],\n whenNotToUse: [\n { when: \"For a feed that plays one video per screen.\", use: \"vertical-video\" },\n { when: \"For still photos the owner reorders.\", use: \"photo-grid\" },\n ],\n bestPractices: [\n \"Leave `mode` on `split` for a feed: a 9:16 card in a 16:9 grid is what makes a row tower over its neighbours.\",\n \"Give `orientationOf` the source's width and height, not the card's: a clip of the other shape is drawn with `fit=\\\"contain\\\"`, never cropped to a face.\",\n \"Put what is the product's own in the slots (`badges`, `action`); badges sit on `scrim` and read in both themes.\",\n \"Pass every word translated: the shelf's name, its arrows and “See all”.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Each card is one link (its title); the creator and the action are their own stops; the shelf takes the focus to scroll with the arrow keys.\" },\n { keys: \"Arrow Left / Arrow Right\", action: \"Scrolls the focused shelf.\" },\n ],\n notes: [\n \"The shelf is a labelled section whose row is a list; the arrows are named buttons shown with a mouse, hidden on touch (swipe).\",\n \"The card's whole surface is the title's link (a stretched link): no nested interactive element.\",\n \"The animated preview plays on hover or focus only.\",\n ],\n },\n related: [\"vertical-video\", \"card\", \"photo-grid\"],\n web: {\n imports: [\"MediaGrid\", \"ShortsShelf\", \"VideoCard\"],\n examples: [\n { name: \"feed\", title: \"A feed\", description: \"Three horizontal clips in the grid, three vertical on the shelf: every card of a row has one height.\" },\n { name: \"uniform\", title: \"One frame\", description: \"`mode=\\\"uniform\\\"`: vertical clips drawn whole over a blurred copy inside 16:9 cards.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
343
+ },
344
+ {
345
+ "path": "media-grid/shorts-shelf.tsx",
346
+ "content": "// Client (the arrows scroll the row). A horizontal row of 9:16 cards of one height: scroll-snap, arrows with a mouse,\n// a swipe on touch, a \"See all\" link. The row is a labelled list that scrolls with the keyboard (it takes focus).\nimport * as React from \"react\";\n\nimport { BlockLink, type LinkComponent } from \"../block-link\";\nimport { cn } from \"../cn\";\n\nexport interface ShortsShelfProps<T> extends Omit<React.ComponentProps<\"section\">, \"children\" | \"title\"> {\n /** The shelf's accessible name (\"Shorts\"), passed translated; shown as its heading unless `title` is set. */\n label: string;\n /** A heading different from the name. */\n title?: React.ReactNode;\n /** The clips, in order. */\n items: readonly T[];\n /** A stable key for an item. */\n getId: (item: T) => string;\n /** One card: use `VideoCard aspect=\"portrait\"`. */\n renderItem: (item: T, index: number) => React.ReactNode;\n /** Opens the vertical feed. */\n seeAll?: { href: string; label: string; linkAs?: LinkComponent };\n /** The arrows' names, passed translated. */\n previousLabel: string;\n /** The next arrow's name. */\n nextLabel: string;\n}\n\nconst Chevron = ({ flip }: { flip?: boolean }) => (\n <svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={flip ? \"rotate-180\" : undefined}>\n <path d=\"m9 6 6 6-6 6\" />\n </svg>\n);\n\nconst ARROW =\n \"hidden h-9 w-9 items-center justify-center rounded-full border border-border-default bg-surface-1 text-fg shadow-sm transition-colors hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40 [@media(hover:hover)]:inline-flex\";\n\n/** A Shorts shelf: equal-height 9:16 cards in a snap-scrolling row. */\nexport function ShortsShelf<T>({ label, title, items, getId, renderItem, seeAll, previousLabel, nextLabel, className, ...props }: ShortsShelfProps<T>) {\n const row = React.useRef<HTMLUListElement>(null);\n const [edge, setEdge] = React.useState({ start: true, end: false });\n const measure = React.useCallback(() => {\n const el = row.current;\n if (!el) return;\n setEdge({ start: el.scrollLeft <= 2, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 2 });\n }, []);\n React.useEffect(measure, [measure, items.length]);\n const page = (dir: 1 | -1) => {\n const el = row.current;\n if (el) el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\" });\n };\n const headingId = React.useId();\n if (items.length === 0) return null;\n return (\n <section aria-labelledby={headingId} data-shorts-shelf className={cn(\"min-w-0\", className)} {...props}>\n <div className=\"mb-3 flex items-center gap-2\">\n <h2 id={headingId} className=\"m-0 mr-auto text-base font-bold text-fg\">\n {title ?? label}\n </h2>\n {seeAll && (\n <BlockLink href={seeAll.href} linkAs={seeAll.linkAs} className=\"text-sm font-semibold text-fg-accent hover:underline\">\n {seeAll.label}\n </BlockLink>\n )}\n <button type=\"button\" aria-label={previousLabel} disabled={edge.start} onClick={() => page(-1)} className={ARROW}>\n <Chevron flip />\n </button>\n <button type=\"button\" aria-label={nextLabel} disabled={edge.end} onClick={() => page(1)} className={ARROW}>\n <Chevron />\n </button>\n </div>\n <ul\n ref={row}\n onScroll={measure}\n // biome-ignore: a scroll container must take the keyboard focus to scroll with the arrow keys\n tabIndex={0}\n aria-labelledby={headingId}\n className=\"m-0 flex list-none snap-x snap-mandatory items-stretch gap-3 overflow-x-auto overscroll-x-contain p-0 pb-2 [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-scrollbar]:hidden\"\n >\n {items.map((item, i) => (\n <li key={getId(item)} className=\"w-[9.5rem] shrink-0 snap-start sm:w-44\">\n {renderItem(item, i)}\n </li>\n ))}\n </ul>\n </section>\n );\n}\n"
347
+ },
348
+ {
349
+ "path": "media-grid/video-card.tsx",
350
+ "content": "// Client (hover / focus swaps the picture for its animated preview). A video in a grid or a shelf: the picture in a 16:9\n// or 9:16 frame (a source of the other shape is shown inside, never stretched), badges, duration, the title on two lines\n// reserved, the creator, a count and one quick action — the same height of body in every card so a row aligns.\nimport * as React from \"react\";\n\nimport { Avatar } from \"../avatar\";\nimport type { DefaultAvatar } from \"../avatar/default-avatar\";\nimport { BlockLink, type LinkComponent } from \"../block-link\";\nimport { Card } from \"../card\";\nimport { cn } from \"../cn\";\n\nexport type VideoCardAspect = \"landscape\" | \"portrait\";\n\nexport interface VideoCardCreator {\n /** The creator's name. */\n name: string;\n /** Their photo. */\n avatarSrc?: string | null;\n /** Their profile. */\n href?: string;\n /** The Krizaka default picture when there is no photo (a brand and a seed). */\n defaultAvatar?: DefaultAvatar;\n}\n\nexport interface VideoCardProps extends Omit<React.ComponentProps<\"article\">, \"title\"> {\n /** The title; it names the card and is its link. */\n title: string;\n /** Where the card opens. */\n href: string;\n /** The app's router link (Next's `Link`). */\n linkAs?: LinkComponent;\n /**\n * The frame: `landscape` (16:9) or `portrait` (9:16).\n * @default \"landscape\"\n */\n aspect?: VideoCardAspect;\n /**\n * `stack` (picture over text) or `row` (picture beside the text: the list mode of a section).\n * @default \"stack\"\n */\n layout?: \"stack\" | \"row\";\n /** The picture. */\n poster?: string | null;\n /** An animated picture (WebP) shown while the card is hovered or focused. */\n previewSrc?: string | null;\n /**\n * `cover` fills the frame; `contain` draws the whole picture over a blurred copy of itself (a source of the other\n * orientation: no cropped face, no black bar).\n * @default \"cover\"\n */\n fit?: \"cover\" | \"contain\";\n /** The length, already formatted (`3:42`). */\n duration?: string;\n /** Badges at the top-left of the picture (Free, a price, 18+). Put them on `scrim` so they read in both themes. */\n badges?: React.ReactNode;\n /** Who made it: name, photo, profile. */\n creator?: VideoCardCreator;\n /** What is counted (`12.4K views`), already formatted. */\n meta?: React.ReactNode;\n /** One quick action at the end of the footer (a tip, a save). Its clicks must not follow the card's link. */\n action?: React.ReactNode;\n /** A sensitive picture: blurred and scaled so nothing shows (put the reveal button in `overlay`). */\n blurred?: boolean;\n /** Over the whole picture, above the card's link (a veil, a reveal button). */\n overlay?: React.ReactNode;\n /** Shown instead of the picture while there is none. */\n fallback?: React.ReactNode;\n /** `eager` for the first cards of a page. */\n loading?: \"lazy\" | \"eager\";\n}\n\nconst FRAME = { landscape: \"aspect-video\", portrait: \"aspect-[9/16]\" } as const;\n\n/** A video card whose frame, body and footer have one height per aspect, so a grid row or a shelf never staggers. */\nexport function VideoCard({\n title,\n href,\n linkAs,\n aspect = \"landscape\",\n layout = \"stack\",\n poster,\n previewSrc,\n fit = \"cover\",\n duration,\n badges,\n creator,\n meta,\n action,\n fallback,\n blurred = false,\n overlay,\n loading = \"lazy\",\n className,\n onMouseEnter,\n onMouseLeave,\n onFocus,\n onBlur,\n ...props\n}: VideoCardProps) {\n const [live, setLive] = React.useState(false);\n const picture = live && previewSrc ? previewSrc : poster;\n const row = layout === \"row\";\n const tall = aspect === \"portrait\" && !row;\n return (\n <article\n data-aspect={aspect}\n data-layout={layout}\n className={cn(\"group relative h-full min-w-0\", className)}\n onMouseEnter={(e) => (setLive(true), onMouseEnter?.(e))}\n onMouseLeave={(e) => (setLive(false), onMouseLeave?.(e))}\n onFocus={(e) => (setLive(true), onFocus?.(e))}\n onBlur={(e) => (setLive(false), onBlur?.(e))}\n {...props}\n >\n <Card.Root interactive tone=\"glass\" className={cn(\"h-full w-full max-w-full\", row ? \"flex-row\" : \"flex-col\")}>\n <Card.Media aspect=\"auto\" className={cn(\"shrink-0\", FRAME[aspect], row && (aspect === \"portrait\" ? \"w-24 sm:w-28\" : \"w-40 sm:w-56\"))}>\n {fit === \"contain\" && picture && <img src={picture} alt=\"\" aria-hidden loading=\"lazy\" className=\"absolute inset-0 h-full w-full scale-125 object-cover opacity-70 blur-2xl\" />}\n <Card.Image src={picture} loading={loading} fallback={fallback} className={cn(fit === \"contain\" && \"relative object-contain\", blurred && \"scale-110 blur-xl\")} />\n {overlay && <div className=\"absolute inset-0 z-20 flex\">{overlay}</div>}\n {badges && (\n <Card.Overlay corner=\"top-left\" className=\"theme-dark flex-wrap\">\n {badges}\n </Card.Overlay>\n )}\n {duration && (\n <Card.Overlay corner=\"bottom-right\">\n <span className=\"rounded-sm bg-scrim px-1.5 py-0.5 font-mono text-[11px] text-fg-on-media\">{duration}</span>\n </Card.Overlay>\n )}\n </Card.Media>\n <Card.Body className=\"min-w-0 flex-1 justify-between gap-2 p-3\">\n <h3 className=\"m-0 min-h-[2.5em] text-sm font-semibold leading-tight text-fg\">\n <BlockLink href={href} linkAs={linkAs} className=\"line-clamp-2 outline-none after:absolute after:inset-0 after:z-0 after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-ring\">\n {title}\n </BlockLink>\n </h3>\n {/* A narrow card (9:16 on a shelf) puts the count and the action on a second line so the name is never cut to an initial. */}\n <div className={cn(\"relative z-10 text-xs text-fg-secondary\", tall ? \"flex flex-col gap-1\" : \"flex h-8 items-center gap-2\")}>\n {creator && (\n <div className=\"flex h-6 min-w-0 items-center gap-2\">\n <Avatar src={creator.avatarSrc} defaultAvatar={creator.defaultAvatar} alt=\"\" fallback={creator.name.charAt(0)} size=\"xs\" className=\"shrink-0\" />\n {creator.href ? (\n <BlockLink href={creator.href} linkAs={linkAs} className=\"min-w-0 truncate hover:text-fg\">\n {creator.name}\n </BlockLink>\n ) : (\n <span className=\"min-w-0 truncate\">{creator.name}</span>\n )}\n </div>\n )}\n {(meta || action) && (\n <div className={cn(\"flex items-center gap-2\", tall ? \"h-7 justify-between\" : \"ml-auto shrink-0\")}>\n {meta && <span className=\"shrink-0 font-mono text-[11px] text-fg-muted\">{meta}</span>}\n {action && <span className=\"shrink-0\">{action}</span>}\n </div>\n )}\n </div>\n </Card.Body>\n </Card.Root>\n </article>\n );\n}\n"
351
+ },
352
+ {
353
+ "path": "meta.ts",
354
+ "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 ui.krizaka.com (apps/site) renders as\n// ui.krizaka.com/components/<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 | \"document-viewer\"\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 | \"markdown\"\n | \"longform-page\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"match-dialog\"\n | \"media-capture\"\n | \"media-grid\"\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 | \"recipes\"\n | \"screen\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"settings-layout\"\n | \"showcase\"\n | \"site-footer\"\n | \"sites\"\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 ui.krizaka.com/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"
355
+ }
356
+ ],
357
+ "dependencies": [],
358
+ "registryDependencies": [
359
+ "avatar",
360
+ "card",
361
+ "cn"
362
+ ],
363
+ "demo": {
364
+ "path": "examples/media-grid/feed.tsx",
365
+ "content": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { MediaGrid, VideoCard } from \"@krizaka/ui/media-grid\";\n\nconst CDN = \"https://krizaka-assets.b-cdn.net/site\";\nconst WIDE = `${CDN}/products/orazaka/gallery`;\nconst TALL = `${CDN}/docs/orochia/mobile`;\nconst CLIPS = [\n { id: \"tea\", title: \"Pouring the mint tea, slow\", poster: `${WIDE}/tea-pour.d69c3a5700.webp`, tall: false },\n { id: \"story\", title: \"Backstage, a minute before\", poster: `${TALL}/clip-feed-stories-poster.fd7835fa74.webp`, tall: true },\n { id: \"snow\", title: \"First snow on rue Saint-Denis\", poster: `${WIDE}/mtl-snow.e3ecb20e82.webp`, tall: false },\n { id: \"watch\", title: \"The unlock in one tap\", poster: `${TALL}/clip-watch-poster.9fc5d19c39.webp`, tall: true },\n { id: \"desert\", title: \"Desert camp at blue hour\", poster: `${WIDE}/desert-camp.78c6c0d26c.webp`, tall: false },\n { id: \"thanks\", title: \"Three ways to say thanks\", poster: `${TALL}/clip-unlock-poster.57e13fee5f.webp`, tall: true },\n];\n\n// 'split' (the feed default): vertical clips on a Shorts shelf, the others in a 16:9 grid whose cards share one height.\nexport default function MediaGridFeed() {\n return (\n <MediaGrid\n items={CLIPS}\n getId={(c) => c.id}\n orientationOf={(c) => (c.tall ? \"portrait\" : \"landscape\")}\n shelf={{ label: \"Shorts\", previousLabel: \"Previous shorts\", nextLabel: \"Next shorts\", seeAll: { href: \"#shorts\", label: \"See all\" } }}\n renderItem={(c, { aspect, layout }) => (\n <VideoCard href={`#${c.id}`} title={c.title} aspect={aspect} layout={layout} poster={c.poster} duration=\"0:12\" meta=\"12.4K views\" creator={{ name: \"Camille\" }} badges={<Badge tone=\"success\" size=\"sm\" className=\"bg-scrim\">Free</Badge>} />\n )}\n />\n );\n}\n"
366
+ },
367
+ "props": [
368
+ {
369
+ "component": "MediaGrid",
370
+ "description": "A media grid that never mixes heights badly: a shelf for the 9:16, a uniform grid for the rest.",
371
+ "props": [
372
+ {
373
+ "name": "getId",
374
+ "type": "(item: T) => string",
375
+ "default": null,
376
+ "description": "A stable key for an item.",
377
+ "required": true
378
+ },
379
+ {
380
+ "name": "items",
381
+ "type": "readonly T[]",
382
+ "default": null,
383
+ "description": "The videos, in order.",
384
+ "required": true
385
+ },
386
+ {
387
+ "name": "orientationOf",
388
+ "type": "(item: T) => VideoCardAspect",
389
+ "default": null,
390
+ "description": "The source's orientation (from its width and height).",
391
+ "required": true
392
+ },
393
+ {
394
+ "name": "renderItem",
395
+ "type": "(item: T, state: MediaGridState) => ReactNode",
396
+ "default": null,
397
+ "description": "One card, drawn in the frame the grid gives (`state`): use `VideoCard`.",
398
+ "required": true
399
+ },
400
+ {
401
+ "name": "shelf",
402
+ "type": "Pick<ShortsShelfProps<unknown>, \"title\" | \"label\" | \"seeAll\" | \"previousLabel\" | \"nextLabel\">",
403
+ "default": null,
404
+ "description": "The shelf of `split`: its name, \"See all\" and arrows, passed translated.",
405
+ "required": true
406
+ },
407
+ {
408
+ "name": "frame",
409
+ "type": "VideoCardAspect",
410
+ "default": "landscape",
411
+ "description": "The frame of `uniform`.",
412
+ "required": false
413
+ },
414
+ {
415
+ "name": "layout",
416
+ "type": "\"grid\" | \"list\"",
417
+ "default": "grid",
418
+ "description": "`grid` or `list` (one row per video).",
419
+ "required": false
420
+ },
421
+ {
422
+ "name": "mode",
423
+ "type": "\"split\" | \"uniform\"",
424
+ "default": "split",
425
+ "description": "`split`: verticals on the shelf, the others in the grid · `uniform`: all in one frame (`frame`).",
426
+ "required": false
427
+ }
428
+ ]
429
+ },
430
+ {
431
+ "component": "ShortsShelf",
432
+ "description": "A Shorts shelf: equal-height 9:16 cards in a snap-scrolling row.",
433
+ "props": [
434
+ {
435
+ "name": "getId",
436
+ "type": "(item: T) => string",
437
+ "default": null,
438
+ "description": "A stable key for an item.",
439
+ "required": true
440
+ },
441
+ {
442
+ "name": "items",
443
+ "type": "readonly T[]",
444
+ "default": null,
445
+ "description": "The clips, in order.",
446
+ "required": true
447
+ },
448
+ {
449
+ "name": "label",
450
+ "type": "string",
451
+ "default": null,
452
+ "description": "The shelf's accessible name (\"Shorts\"), passed translated; shown as its heading unless `title` is set.",
453
+ "required": true
454
+ },
455
+ {
456
+ "name": "nextLabel",
457
+ "type": "string",
458
+ "default": null,
459
+ "description": "The next arrow's name.",
460
+ "required": true
461
+ },
462
+ {
463
+ "name": "previousLabel",
464
+ "type": "string",
465
+ "default": null,
466
+ "description": "The arrows' names, passed translated.",
467
+ "required": true
468
+ },
469
+ {
470
+ "name": "renderItem",
471
+ "type": "(item: T, index: number) => ReactNode",
472
+ "default": null,
473
+ "description": "One card: use `VideoCard aspect=\"portrait\"`.",
474
+ "required": true
475
+ },
476
+ {
477
+ "name": "seeAll",
478
+ "type": "{ href: string; label: string; linkAs?: LinkComponent; }",
479
+ "default": null,
480
+ "description": "Opens the vertical feed.",
481
+ "required": false
482
+ },
483
+ {
484
+ "name": "title",
485
+ "type": "ReactNode",
486
+ "default": null,
487
+ "description": "A heading different from the name.",
488
+ "required": false
489
+ }
490
+ ]
491
+ },
492
+ {
493
+ "component": "VideoCard",
494
+ "description": "A video card whose frame, body and footer have one height per aspect, so a grid row or a shelf never staggers.",
495
+ "props": [
496
+ {
497
+ "name": "href",
498
+ "type": "string",
499
+ "default": null,
500
+ "description": "Where the card opens.",
501
+ "required": true
502
+ },
503
+ {
504
+ "name": "title",
505
+ "type": "string",
506
+ "default": null,
507
+ "description": "The title; it names the card and is its link.",
508
+ "required": true
509
+ },
510
+ {
511
+ "name": "action",
512
+ "type": "ReactNode",
513
+ "default": null,
514
+ "description": "One quick action at the end of the footer (a tip, a save). Its clicks must not follow the card's link.",
515
+ "required": false
516
+ },
517
+ {
518
+ "name": "aspect",
519
+ "type": "VideoCardAspect",
520
+ "default": "landscape",
521
+ "description": "The frame: `landscape` (16:9) or `portrait` (9:16).",
522
+ "required": false
523
+ },
524
+ {
525
+ "name": "badges",
526
+ "type": "ReactNode",
527
+ "default": null,
528
+ "description": "Badges at the top-left of the picture (Free, a price, 18+). Put them on `scrim` so they read in both themes.",
529
+ "required": false
530
+ },
531
+ {
532
+ "name": "blurred",
533
+ "type": "boolean",
534
+ "default": "false",
535
+ "description": "A sensitive picture: blurred and scaled so nothing shows (put the reveal button in `overlay`).",
536
+ "required": false
537
+ },
538
+ {
539
+ "name": "creator",
540
+ "type": "VideoCardCreator",
541
+ "default": null,
542
+ "description": "Who made it: name, photo, profile.",
543
+ "required": false
544
+ },
545
+ {
546
+ "name": "duration",
547
+ "type": "string",
548
+ "default": null,
549
+ "description": "The length, already formatted (`3:42`).",
550
+ "required": false
551
+ },
552
+ {
553
+ "name": "fallback",
554
+ "type": "ReactNode",
555
+ "default": null,
556
+ "description": "Shown instead of the picture while there is none.",
557
+ "required": false
558
+ },
559
+ {
560
+ "name": "fit",
561
+ "type": "\"cover\" | \"contain\"",
562
+ "default": "cover",
563
+ "description": "`cover` fills the frame; `contain` draws the whole picture over a blurred copy of itself (a source of the other\norientation: no cropped face, no black bar).",
564
+ "required": false
565
+ },
566
+ {
567
+ "name": "layout",
568
+ "type": "\"row\" | \"stack\"",
569
+ "default": "stack",
570
+ "description": "`stack` (picture over text) or `row` (picture beside the text: the list mode of a section).",
571
+ "required": false
572
+ },
573
+ {
574
+ "name": "linkAs",
575
+ "type": "LinkComponent",
576
+ "default": null,
577
+ "description": "The app's router link (Next's `Link`).",
578
+ "required": false
579
+ },
580
+ {
581
+ "name": "loading",
582
+ "type": "\"eager\" | \"lazy\"",
583
+ "default": "lazy",
584
+ "description": "`eager` for the first cards of a page.",
585
+ "required": false
586
+ },
587
+ {
588
+ "name": "meta",
589
+ "type": "ReactNode",
590
+ "default": null,
591
+ "description": "What is counted (`12.4K views`), already formatted.",
592
+ "required": false
593
+ },
594
+ {
595
+ "name": "overlay",
596
+ "type": "ReactNode",
597
+ "default": null,
598
+ "description": "Over the whole picture, above the card's link (a veil, a reveal button).",
599
+ "required": false
600
+ },
601
+ {
602
+ "name": "poster",
603
+ "type": "string | null",
604
+ "default": null,
605
+ "description": "The picture.",
606
+ "required": false
607
+ },
608
+ {
609
+ "name": "previewSrc",
610
+ "type": "string | null",
611
+ "default": null,
612
+ "description": "An animated picture (WebP) shown while the card is hovered or focused.",
613
+ "required": false
614
+ }
615
+ ]
616
+ }
617
+ ]
618
+ }