@bearmenu/ui 0.8.13 → 0.8.15

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 (294) hide show
  1. package/README.md +0 -1
  2. package/dist/{chunk-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
  3. package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
  4. package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
  5. package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
  6. package/dist/{chunk-VAXOQS7W.js → chunk-5FCC5L5F.js} +3 -3
  7. package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
  8. package/dist/chunk-DJD3EMPK.js +126 -0
  9. package/dist/chunk-EZG2NELX.js +5 -0
  10. package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
  11. package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
  12. package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
  13. package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
  14. package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
  15. package/dist/chunk-I5V446BX.js +27 -0
  16. package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
  17. package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
  18. package/dist/chunk-LQWGZIYA.js +5 -0
  19. package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
  20. package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
  21. package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
  22. package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
  23. package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
  24. package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
  25. package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
  26. package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
  27. package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
  28. package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
  29. package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
  30. package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
  31. package/dist/{chunk-3NMG3KNA.js → chunk-SXQZ7DLH.js} +3 -3
  32. package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
  33. package/dist/{chunk-R47NEOSI.js → chunk-V4E333QS.js} +29 -4
  34. package/dist/{chunk-FKNBZF5T.js → chunk-WYTJR2YU.js} +1 -1
  35. package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
  36. package/dist/chunk-XCB3F6SF.js +16 -0
  37. package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
  38. package/dist/{chunk-P7XTW2CG.js → chunk-ZLPWEHZO.js} +1 -1
  39. package/dist/components/accordion.js +1 -1
  40. package/dist/components/alert-dialog.js +3 -3
  41. package/dist/components/alert.js +2 -2
  42. package/dist/components/aspect-ratio.js +2 -2
  43. package/dist/components/avatar.js +1 -1
  44. package/dist/components/badge.js +2 -2
  45. package/dist/components/breadcrumb.js +1 -1
  46. package/dist/components/button-group.js +1 -1
  47. package/dist/components/button.d.ts +4 -0
  48. package/dist/components/button.js +3 -3
  49. package/dist/components/calendar.js +3 -3
  50. package/dist/components/card.js +3 -3
  51. package/dist/components/carousel.js +3 -3
  52. package/dist/components/category-deck.js +2 -2
  53. package/dist/components/checkbox.js +2 -2
  54. package/dist/components/circular-progress.js +1 -1
  55. package/dist/components/collapsible.js +1 -1
  56. package/dist/components/combobox.js +6 -6
  57. package/dist/components/command.js +3 -3
  58. package/dist/components/concept-card.js +2 -2
  59. package/dist/components/concept-compare-rail.js +3 -3
  60. package/dist/components/concept-compare.d.ts +2 -1
  61. package/dist/components/concept-compare.js +5 -5
  62. package/dist/components/concept-convergence.js +4 -4
  63. package/dist/components/convergence.js +3 -3
  64. package/dist/components/coverflow.js +1 -1
  65. package/dist/components/currency-input.js +2 -2
  66. package/dist/components/date-input.js +2 -2
  67. package/dist/components/day-rail-chip.js +2 -2
  68. package/dist/components/description-list.js +1 -1
  69. package/dist/components/dialog.js +2 -2
  70. package/dist/components/doorway-tiles.d.ts +57 -0
  71. package/dist/components/doorway-tiles.js +86 -0
  72. package/dist/components/drawer.js +1 -1
  73. package/dist/components/drift-rail.js +1 -1
  74. package/dist/components/dropdown-menu.js +1 -1
  75. package/dist/components/empty-state.js +4 -4
  76. package/dist/components/event-card.js +4 -4
  77. package/dist/components/event-compact-row.js +4 -4
  78. package/dist/components/event-day-header.js +2 -2
  79. package/dist/components/event-day-separator.js +3 -3
  80. package/dist/components/event-detail-panel.js +5 -5
  81. package/dist/components/event-hub-hero.js +2 -2
  82. package/dist/components/event-index-row.js +4 -4
  83. package/dist/components/event-list-row-wide.js +6 -6
  84. package/dist/components/event-list-row.js +5 -5
  85. package/dist/components/event-poster-fallback.js +1 -1
  86. package/dist/components/event-types.js +1 -1
  87. package/dist/components/events-empty-state.js +1 -1
  88. package/dist/components/explainer-steps.js +1 -1
  89. package/dist/components/facet-group.js +2 -2
  90. package/dist/components/featured-rail-item.js +2 -2
  91. package/dist/components/field.js +2 -2
  92. package/dist/components/filter-category-row.js +3 -3
  93. package/dist/components/form.js +2 -2
  94. package/dist/components/glass-ground.js +2 -2
  95. package/dist/components/grade-atom.d.ts +3 -0
  96. package/dist/components/grade-atom.js +4 -0
  97. package/dist/components/hour-band.js +4 -4
  98. package/dist/components/hour-group.js +4 -4
  99. package/dist/components/hub-surface.js +1 -1
  100. package/dist/components/hue-chip.js +1 -1
  101. package/dist/components/icon.js +1 -1
  102. package/dist/components/input-group.js +1 -1
  103. package/dist/components/input-otp.js +1 -1
  104. package/dist/components/input.js +2 -2
  105. package/dist/components/item.js +1 -1
  106. package/dist/components/kbd.js +1 -1
  107. package/dist/components/kitchen-dossier.d.ts +2 -1
  108. package/dist/components/kitchen-dossier.js +4 -4
  109. package/dist/components/label.js +2 -2
  110. package/dist/components/lazy-markdown.js +1 -1
  111. package/dist/components/link-button.js +3 -3
  112. package/dist/components/markdown-renderer.js +1 -1
  113. package/dist/components/menu-dead-end.d.ts +2 -1
  114. package/dist/components/menu-dead-end.js +1 -1
  115. package/dist/components/menu-family-block.d.ts +2 -1
  116. package/dist/components/menu-family-block.js +15 -9
  117. package/dist/components/menu-family-card.d.ts +2 -1
  118. package/dist/components/menu-family-card.js +1 -1
  119. package/dist/components/menu-field.js +1 -1
  120. package/dist/components/menu-filter-chips.d.ts +2 -1
  121. package/dist/components/menu-filter-chips.js +3 -3
  122. package/dist/components/menu-group-rows.d.ts +2 -1
  123. package/dist/components/menu-group-rows.js +2 -2
  124. package/dist/components/menu-hub-hero.js +1 -1
  125. package/dist/components/menu-index-header.d.ts +2 -1
  126. package/dist/components/menu-index-header.js +2 -2
  127. package/dist/components/menu-index-list.d.ts +2 -1
  128. package/dist/components/menu-index-list.js +5 -5
  129. package/dist/components/menu-intent-hero.d.ts +3 -2
  130. package/dist/components/menu-intent-hero.js +1 -1
  131. package/dist/components/menu-item.d.ts +2 -1
  132. package/dist/components/menu-item.js +4 -4
  133. package/dist/components/menu-kitchen-list.d.ts +2 -1
  134. package/dist/components/menu-kitchen-list.js +4 -4
  135. package/dist/components/menu-mocks.d.ts +16 -5
  136. package/dist/components/menu-mocks.js +23 -2
  137. package/dist/components/menu-types.d.ts +21 -34
  138. package/dist/components/motion-icon-actions.d.ts +27 -0
  139. package/dist/components/motion-icon-actions.js +336 -0
  140. package/dist/components/motion-icon-controls.js +3 -3
  141. package/dist/components/motion-icon-set.d.ts +13 -1
  142. package/dist/components/motion-icon-set.js +197 -6
  143. package/dist/components/motion-icon.d.ts +15 -1
  144. package/dist/components/motion-icon.js +2 -2
  145. package/dist/components/multi-select-combobox.js +8 -8
  146. package/dist/components/multi-select-menu.js +2 -2
  147. package/dist/components/native-select.js +1 -1
  148. package/dist/components/open-now-list.d.ts +2 -1
  149. package/dist/components/open-now-list.js +2 -2
  150. package/dist/components/pagination.js +3 -3
  151. package/dist/components/phone-frame.js +1 -1
  152. package/dist/components/photo-ground.js +1 -1
  153. package/dist/components/place-about-tab.d.ts +2 -1
  154. package/dist/components/place-about-tab.js +6 -6
  155. package/dist/components/place-card.d.ts +94 -12
  156. package/dist/components/place-card.js +203 -128
  157. package/dist/components/place-details-mobile.d.ts +2 -1
  158. package/dist/components/place-details-mobile.js +7 -7
  159. package/dist/components/place-schedule-week.d.ts +2 -1
  160. package/dist/components/place-schedule-week.js +3 -3
  161. package/dist/components/place-types.d.ts +3 -265
  162. package/dist/components/popover.js +2 -2
  163. package/dist/components/poster-stack.js +1 -1
  164. package/dist/components/poster-tape.js +1 -1
  165. package/dist/components/poster-wall.d.ts +22 -6
  166. package/dist/components/poster-wall.js +197 -12
  167. package/dist/components/progress.js +1 -1
  168. package/dist/components/qa-block.d.ts +2 -1
  169. package/dist/components/qa-block.js +1 -1
  170. package/dist/components/quiet-stretch.js +1 -1
  171. package/dist/components/radio-group.js +1 -1
  172. package/dist/components/rating.js +1 -1
  173. package/dist/components/scroll-area.js +1 -1
  174. package/dist/components/scroll-fade.js +2 -2
  175. package/dist/components/search-bar.js +1 -1
  176. package/dist/components/searchable-select.js +6 -6
  177. package/dist/components/section-header.js +2 -2
  178. package/dist/components/segmented-nav.d.ts +24 -4
  179. package/dist/components/segmented-nav.js +11 -2
  180. package/dist/components/select.js +1 -1
  181. package/dist/components/selection-bar.js +1 -1
  182. package/dist/components/separator.js +2 -2
  183. package/dist/components/sheet.js +1 -1
  184. package/dist/components/signature-chip.js +3 -3
  185. package/dist/components/skeleton-card.js +3 -3
  186. package/dist/components/skeleton.js +2 -2
  187. package/dist/components/slider.js +1 -1
  188. package/dist/components/sliding-panels.js +3 -3
  189. package/dist/components/snap-rail.js +2 -2
  190. package/dist/components/sonner.js +1 -1
  191. package/dist/components/sort-option-list.js +1 -1
  192. package/dist/components/spinner.js +1 -1
  193. package/dist/components/stat-banner.js +1 -1
  194. package/dist/components/stepper.js +1 -1
  195. package/dist/components/sticky-container.js +1 -1
  196. package/dist/components/story.js +1 -1
  197. package/dist/components/switch.js +1 -1
  198. package/dist/components/table.js +1 -1
  199. package/dist/components/tabs.js +2 -2
  200. package/dist/components/tag-group.js +2 -2
  201. package/dist/components/tag.js +2 -2
  202. package/dist/components/text.js +2 -2
  203. package/dist/components/textarea.js +1 -1
  204. package/dist/components/timeline-band.js +4 -4
  205. package/dist/components/timeline-playhead.js +4 -4
  206. package/dist/components/toggle-group.js +2 -2
  207. package/dist/components/toggle.js +2 -2
  208. package/dist/components/tooltip.js +1 -1
  209. package/dist/components/top-comparison.d.ts +2 -1
  210. package/dist/components/top-comparison.js +1 -1
  211. package/dist/components/top-cross-link.d.ts +2 -1
  212. package/dist/components/top-cross-link.js +1 -1
  213. package/dist/components/top-ranking-entry.d.ts +2 -1
  214. package/dist/components/top-ranking-entry.js +6 -6
  215. package/dist/components/top-ranking-hero.d.ts +2 -1
  216. package/dist/components/top-ranking-hero.js +1 -1
  217. package/dist/components/top-ranking-legend.js +1 -1
  218. package/dist/components/top-types.d.ts +3 -1
  219. package/dist/components/use-mobile.js +1 -1
  220. package/dist/components/venue-events-block.js +5 -5
  221. package/dist/components/venue-grade-dimensions.d.ts +2 -1
  222. package/dist/components/venue-grade-dimensions.js +1 -1
  223. package/dist/components/venue-quotes.d.ts +2 -1
  224. package/dist/components/venue-quotes.js +1 -1
  225. package/dist/components/venue-wall.d.ts +2 -1
  226. package/dist/components/venue-wall.js +1 -1
  227. package/dist/components/weekday-theme-banner.js +1 -1
  228. package/dist/components/weekend-panel.js +4 -4
  229. package/dist/hooks/use-drag-swipe.js +1 -1
  230. package/dist/hooks/use-haptic.js +2 -2
  231. package/dist/hooks/use-media-query.js +1 -1
  232. package/dist/hooks/use-mobile.js +1 -1
  233. package/dist/hooks/use-mounted-theme.js +1 -1
  234. package/dist/hooks/use-sticky.js +1 -1
  235. package/dist/lib/card-hover.js +2 -18
  236. package/dist/lib/grade.d.ts +54 -0
  237. package/dist/lib/grade.js +2 -0
  238. package/dist/lib/icon-depth.d.ts +35 -0
  239. package/dist/lib/icon-depth.js +2 -0
  240. package/dist/lib/icon-registry.js +1 -1
  241. package/dist/lib/motion.js +2 -2
  242. package/dist/lib/overlay-pill.d.ts +30 -0
  243. package/dist/lib/overlay-pill.js +2 -0
  244. package/dist/lib/utils.js +1 -1
  245. package/dist/place-types-BHdeMMiG.d.ts +317 -0
  246. package/package.json +1 -1
  247. package/src/components/button.tsx +5 -1
  248. package/src/components/colour-dictionary.stories.tsx +268 -82
  249. package/src/components/doorway-tiles.stories.tsx +83 -0
  250. package/src/components/doorway-tiles.test.tsx +51 -0
  251. package/src/components/doorway-tiles.tsx +225 -0
  252. package/src/components/grade-atom.stories.tsx +81 -0
  253. package/src/components/grade-atom.tsx +256 -0
  254. package/src/components/hue-chip.stories.tsx +2 -2
  255. package/src/components/menu-family-block.stories.tsx +29 -0
  256. package/src/components/menu-family-block.test.tsx +51 -0
  257. package/src/components/menu-family-block.tsx +20 -4
  258. package/src/components/menu-intent-hero.tsx +1 -1
  259. package/src/components/menu-mocks.ts +39 -3
  260. package/src/components/menu-types.ts +17 -33
  261. package/src/components/motion-icon-actions.test.tsx +115 -0
  262. package/src/components/motion-icon-actions.tsx +580 -0
  263. package/src/components/motion-icon-controls.test.tsx +1 -0
  264. package/src/components/motion-icon-controls.tsx +18 -13
  265. package/src/components/motion-icon-set.stories.tsx +62 -3
  266. package/src/components/motion-icon-set.tsx +349 -2
  267. package/src/components/motion-icon.test.tsx +180 -1
  268. package/src/components/motion-icon.tsx +97 -8
  269. package/src/components/place-card.stories.tsx +66 -76
  270. package/src/components/place-card.test.tsx +188 -100
  271. package/src/components/place-card.tsx +356 -148
  272. package/src/components/place-types.ts +17 -4
  273. package/src/components/poster-wall.stories.tsx +84 -0
  274. package/src/components/poster-wall.test.tsx +94 -3
  275. package/src/components/poster-wall.tsx +257 -9
  276. package/src/components/segmented-nav.stories.tsx +72 -1
  277. package/src/components/segmented-nav.test.tsx +96 -0
  278. package/src/components/segmented-nav.tsx +36 -4
  279. package/src/globals.css +26 -0
  280. package/src/hooks/use-haptic.test.tsx +80 -0
  281. package/src/hooks/use-haptic.ts +45 -4
  282. package/src/lib/grade.test.ts +82 -0
  283. package/src/lib/grade.ts +76 -0
  284. package/src/lib/icon-depth.ts +35 -0
  285. package/src/lib/overlay-pill.ts +29 -0
  286. package/dist/components/haptic-button.d.ts +0 -12
  287. package/dist/components/haptic-button.js +0 -17
  288. package/dist/components/menu-family-tiles.d.ts +0 -19
  289. package/dist/components/menu-family-tiles.js +0 -57
  290. package/src/components/haptic-button.stories.tsx +0 -41
  291. package/src/components/haptic-button.tsx +0 -19
  292. package/src/components/menu-family-tiles.stories.tsx +0 -54
  293. package/src/components/menu-family-tiles.test.tsx +0 -30
  294. package/src/components/menu-family-tiles.tsx +0 -132
@@ -80,6 +80,90 @@ export const NoTiles: Story = {
80
80
  args: { children: <h1 className="text-3xl font-black">No poster data yet</h1> },
81
81
  };
82
82
 
83
+ function StripFrame({
84
+ width,
85
+ height,
86
+ children,
87
+ }: {
88
+ width: number;
89
+ height: number;
90
+ children: React.ReactNode;
91
+ }) {
92
+ return (
93
+ <div
94
+ className="relative mx-auto overflow-hidden rounded-2xl"
95
+ style={{ width, height }}
96
+ >
97
+ {children}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The banner backdrop: fills the caller's box, two rows of 4:5 tiles filled
104
+ * from the right edge (soonest-first), full-opacity posters under a veil that
105
+ * is dense on the left and clears on the right.
106
+ */
107
+ export const Strip: Story = {
108
+ render: () => (
109
+ <StripFrame width={343} height={150}>
110
+ <PosterWall variant="strip" tiles={tiles(12)} />
111
+ <div className="relative flex h-full flex-col justify-between p-4 text-white">
112
+ <h2 className="max-w-[62%] font-serif text-2xl font-semibold leading-[1.05]">
113
+ Today in Cluj-Napoca
114
+ </h2>
115
+ <span className="text-xs font-semibold uppercase tracking-[0.14em] text-white/70">
116
+ 4 events today
117
+ </span>
118
+ </div>
119
+ </StripFrame>
120
+ ),
121
+ };
122
+
123
+ /** The desktop hero box: thirteen columns of 111px posters. */
124
+ export const StripHero: Story = {
125
+ render: () => (
126
+ <StripFrame width={1408} height={280}>
127
+ <PosterWall variant="strip" tiles={tiles(26)} />
128
+ <div className="relative flex h-full max-w-[46%] flex-col justify-center gap-3 px-10 text-white">
129
+ <span className="text-xs font-semibold uppercase tracking-[0.16em] text-white/70">
130
+ 33 upcoming events
131
+ </span>
132
+ <h2 className="font-serif text-[44px] leading-[1.06] tracking-[-0.01em]">
133
+ Up next in Cluj-Napoca
134
+ </h2>
135
+ </div>
136
+ </StripFrame>
137
+ ),
138
+ };
139
+
140
+ /** A sparse city: three posters, lit at the right edge; the rest is wall. */
141
+ export const StripSparse: Story = {
142
+ render: () => (
143
+ <StripFrame width={343} height={150}>
144
+ <PosterWall variant="strip" tiles={tiles(3)} />
145
+ </StripFrame>
146
+ ),
147
+ };
148
+
149
+ /** No events at all: every slot is the placeholder and the sweep is off. */
150
+ export const StripEmpty: Story = {
151
+ render: () => (
152
+ <StripFrame width={343} height={150}>
153
+ <PosterWall variant="strip" tiles={[]} sweep={false} />
154
+ </StripFrame>
155
+ ),
156
+ };
157
+
158
+ /** The strip's loading state, at rest. */
159
+ export const StripSkeleton: Story = {
160
+ render: () => (
161
+ <StripFrame width={343} height={150}>
162
+ <PosterWallSkeleton variant="strip" />
163
+ </StripFrame>
164
+ ),
165
+ };
166
+
83
167
  /** The tile grid at rest — no sweeping light in the loading state. */
84
168
  export const Skeleton: Story = {
85
169
  render: () => <PosterWallSkeleton />,
@@ -1,10 +1,101 @@
1
- import { describe, it, expect } from "vitest";
2
- import { render } from "@testing-library/react";
3
- import { PosterWall } from "./poster-wall";
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { act, render } from "@testing-library/react";
3
+ import { PosterWall, stripGeometry } from "./poster-wall";
4
4
 
5
5
  const grid = (container: HTMLElement) =>
6
6
  container.querySelector<HTMLElement>("[aria-hidden].grid")!;
7
7
 
8
+ /**
9
+ * The setup's ResizeObserver never fires; the strip needs one measurement.
10
+ * Returns a `measure` that delivers it to every observed node, and a `restore`.
11
+ */
12
+ function observeBox(width: number, height: number) {
13
+ const Original = globalThis.ResizeObserver;
14
+ const observers: { callback: ResizeObserverCallback; target: Element }[] =
15
+ [];
16
+ globalThis.ResizeObserver = class {
17
+ constructor(private callback: ResizeObserverCallback) {}
18
+ observe(target: Element) {
19
+ observers.push({ callback: this.callback, target });
20
+ }
21
+ unobserve() {}
22
+ disconnect() {}
23
+ };
24
+ return {
25
+ measure: () =>
26
+ act(() => {
27
+ for (const { callback, target } of observers) {
28
+ const entry = {
29
+ target,
30
+ contentRect: { width, height },
31
+ } as unknown as ResizeObserverEntry;
32
+ callback([entry], {} as ResizeObserver);
33
+ }
34
+ }),
35
+ restore: () => {
36
+ globalThis.ResizeObserver = Original;
37
+ },
38
+ };
39
+ }
40
+
41
+ describe("stripGeometry", () => {
42
+ it("cuts 4:5 tiles in two rows and covers the width", () => {
43
+ // 343×150 phone strip: (150 − 2) / 2 = 74 tall → 59 wide → 6 columns
44
+ expect(stripGeometry(343, 150)).toEqual({
45
+ tileWidth: 59,
46
+ cols: 6,
47
+ slotCount: 12,
48
+ });
49
+ // 1408×280 desktop hero: 139 tall → 111 wide → 13 columns
50
+ expect(stripGeometry(1408, 280)).toEqual({
51
+ tileWidth: 111,
52
+ cols: 13,
53
+ slotCount: 26,
54
+ });
55
+ });
56
+
57
+ it("caps the slot count", () => {
58
+ expect(stripGeometry(4000, 120).slotCount).toBe(32);
59
+ });
60
+ });
61
+
62
+ describe("PosterWall strip", () => {
63
+ let restore: (() => void) | undefined;
64
+ afterEach(() => restore?.());
65
+
66
+ it("paints no tiles before the box is measured", () => {
67
+ const { container } = render(
68
+ <PosterWall variant="strip" tiles={[{ src: "p0.jpg" }]} />,
69
+ );
70
+ expect(container.querySelector("[aria-hidden].grid")).toBeNull();
71
+ });
72
+
73
+ it("fills from the right, never cycles, and pads with placeholders", () => {
74
+ const box = observeBox(343, 150);
75
+ restore = box.restore;
76
+ const tiles = Array.from({ length: 3 }, (_, i) => ({ src: `p${i}.jpg` }));
77
+ const { container } = render(<PosterWall variant="strip" tiles={tiles} />);
78
+ box.measure();
79
+ const g = grid(container);
80
+ expect(g.style.gridAutoColumns).toBe("59px");
81
+ expect(g.children).toHaveLength(12);
82
+ expect(g.querySelectorAll("img")).toHaveLength(3);
83
+ // DOM order is fill order; `direction: rtl` puts index 0 at the top-right.
84
+ expect(g.children[0].getAttribute("src")).toBe("p0.jpg");
85
+ expect(g.children[3].tagName).toBe("DIV");
86
+ });
87
+
88
+ it("keeps the sweep paused when the caller turns it off", () => {
89
+ const { container } = render(
90
+ <PosterWall variant="strip" tiles={[]} sweep={false} />,
91
+ );
92
+ const sweep = container.querySelector<HTMLElement>(
93
+ "[style*='animation-name']",
94
+ )!;
95
+ expect(sweep.style.animationPlayState).toBe("paused");
96
+ });
97
+ });
98
+
8
99
  describe("PosterWall grid", () => {
9
100
  it("sizes the grid to the tiles and completes the last row by cycling them", () => {
10
101
  const tiles = Array.from({ length: 5 }, (_, i) => ({ src: `p${i}.jpg` }));
@@ -30,11 +30,65 @@ import { Skeleton } from "./skeleton";
30
30
  * indefinitely at zero React cost — the keyframes are scoped to this file via
31
31
  * a `<style>` tag since editing the DS's global stylesheet is out of scope
32
32
  * for this component.
33
+ *
34
+ * ## `strip` — the wall as a banner backdrop
35
+ *
36
+ * The third variant is the one that does NOT own its box: it fills the
37
+ * caller's (`absolute inset-0`, no height, no radius) so a 16:7 banner or a
38
+ * 280px hero can put the wall behind its own type. Everything about it
39
+ * follows from the tiles having to be legible at ~75px tall:
40
+ *
41
+ * - Tiles are 4:5 portrait in two fixed rows — the shape says "posters", and
42
+ * a portrait crop keeps a poster's headline word visible at 60px wide.
43
+ * - The grid fills column-major FROM THE RIGHT EDGE (`direction: rtl`), so
44
+ * `tiles[0]` is the lit top-right poster and the caller passes them
45
+ * soonest-first. The veil is dense on the left, where the type lives, and
46
+ * clears on the right, where the posters show.
47
+ * - Tiles render at full opacity and the veil does all the darkening: the only
48
+ * way one grid can be wall texture on the left and real artwork on the right.
49
+ * - No cycling. Legible repetition reads as a data bug ("the same poster four
50
+ * times"); slots past the last picture take the striped placeholder, and
51
+ * because the fill starts at the right they land under the dense veil where
52
+ * they read as wall. A sparse city is the common case, not a fallback.
53
+ * - The grid mounts after the first measurement (base and veil paint at once),
54
+ * with `motion-image-enter`, so the tile count never reflows on screen.
33
55
  */
34
56
 
35
57
  const POSTER_WALL_DESKTOP_TILE_COUNT = 48;
36
58
  const POSTER_WALL_COMPACT_TILE_COUNT = 36;
59
+ const POSTER_WALL_STRIP_TILE_COUNT = 32;
60
+ const POSTER_WALL_STRIP_ROWS = 2;
61
+ const POSTER_WALL_STRIP_TILE_ASPECT = 4 / 5;
62
+ const POSTER_WALL_STRIP_GAP_PX = 2;
37
63
  const POSTER_WALL_SWEEP_KEYFRAMES_ID = "ds-poster-wall-sweep-keyframes";
64
+ const POSTER_WALL_BASE = "oklch(0.22 0.012 60)";
65
+ const POSTER_WALL_VEIL = "oklch(0.2 0.01 60";
66
+
67
+ const STRIP_VEIL = `linear-gradient(90deg, ${POSTER_WALL_VEIL} / 0.92) 0%, ${POSTER_WALL_VEIL} / 0.78) 50%, ${POSTER_WALL_VEIL} / 0.4) 75%, ${POSTER_WALL_VEIL} / 0.25) 100%)`;
68
+ const STRIP_VEIL_WIDE = `linear-gradient(90deg, ${POSTER_WALL_VEIL} / 0.92) 0%, ${POSTER_WALL_VEIL} / 0.8) 40%, ${POSTER_WALL_VEIL} / 0.45) 62%, ${POSTER_WALL_VEIL} / 0.22) 100%)`;
69
+
70
+ const PLACEHOLDER_TILE_CLASS =
71
+ "h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]";
72
+
73
+ /** Tile geometry for a measured strip box: 4:5 tiles in two rows, as many columns as cover the width. */
74
+ export function stripGeometry(width: number, height: number) {
75
+ const rowHeight =
76
+ (height - POSTER_WALL_STRIP_GAP_PX * (POSTER_WALL_STRIP_ROWS - 1)) /
77
+ POSTER_WALL_STRIP_ROWS;
78
+ const tileWidth = Math.max(
79
+ 1,
80
+ Math.round(rowHeight * POSTER_WALL_STRIP_TILE_ASPECT),
81
+ );
82
+ const cols = Math.max(
83
+ 1,
84
+ Math.ceil(width / (tileWidth + POSTER_WALL_STRIP_GAP_PX)),
85
+ );
86
+ const slotCount = Math.min(
87
+ cols * POSTER_WALL_STRIP_ROWS,
88
+ POSTER_WALL_STRIP_TILE_COUNT,
89
+ );
90
+ return { tileWidth, cols, slotCount };
91
+ }
38
92
 
39
93
  function usePrefersReducedMotion(): boolean {
40
94
  const [reduced, setReduced] = React.useState(false);
@@ -78,11 +132,22 @@ export type PosterWallTile = {
78
132
  src?: string;
79
133
  };
80
134
 
135
+ export type PosterWallVariant = "default" | "compact" | "strip";
136
+
81
137
  export type PosterWallProps = {
82
138
  /** Background tiles. The grid sizes itself to how many there are (see above); with none, a placeholder grid renders. */
83
139
  tiles?: PosterWallTile[];
84
- /** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
85
- variant?: "default" | "compact";
140
+ /**
141
+ * `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a
142
+ * top-fading veil). `strip` fills the caller's box as a banner backdrop —
143
+ * two rows of portrait tiles filled from the right edge, soonest-first.
144
+ */
145
+ variant?: PosterWallVariant;
146
+ /**
147
+ * Whether the light sweeps. Defaults on; a caller turns it off for a wall
148
+ * with nothing on it (an empty strip is a status surface, not a stage).
149
+ */
150
+ sweep?: boolean;
86
151
  /** The overlay content — fully caller-composed. */
87
152
  children?: React.ReactNode;
88
153
  className?: string;
@@ -91,9 +156,162 @@ export type PosterWallProps = {
91
156
  export function PosterWall({
92
157
  tiles,
93
158
  variant = "default",
159
+ sweep = true,
94
160
  children,
95
161
  className,
96
162
  }: PosterWallProps) {
163
+ if (variant === "strip") {
164
+ return (
165
+ <PosterStrip tiles={tiles} sweep={sweep} className={className}>
166
+ {children}
167
+ </PosterStrip>
168
+ );
169
+ }
170
+ return (
171
+ <PosterWallMasthead
172
+ tiles={tiles}
173
+ variant={variant}
174
+ sweep={sweep}
175
+ className={className}
176
+ >
177
+ {children}
178
+ </PosterWallMasthead>
179
+ );
180
+ }
181
+
182
+ function PosterStrip({
183
+ tiles,
184
+ sweep,
185
+ children,
186
+ className,
187
+ }: {
188
+ tiles?: PosterWallTile[];
189
+ sweep: boolean;
190
+ children?: React.ReactNode;
191
+ className?: string;
192
+ }) {
193
+ const pictures = React.useMemo(
194
+ () => (tiles ?? []).filter((tile) => !!tile.src),
195
+ [tiles],
196
+ );
197
+
198
+ const prefersReducedMotion = usePrefersReducedMotion();
199
+ const [wallRef, inView] = useInViewport<HTMLDivElement>();
200
+ const isSweepPaused = !sweep || prefersReducedMotion || !inView;
201
+
202
+ // Nothing is assumed about the box: the grid waits for the first
203
+ // measurement, so the server and the hydrating client paint base + veil only
204
+ // and the tiles arrive once, at their final count.
205
+ const [box, setBox] = React.useState<{
206
+ width: number;
207
+ height: number;
208
+ } | null>(null);
209
+ React.useEffect(() => {
210
+ const node = wallRef.current;
211
+ if (!node || typeof ResizeObserver === "undefined") return;
212
+ const observer = new ResizeObserver(([entry]) =>
213
+ setBox({
214
+ width: entry.contentRect.width,
215
+ height: entry.contentRect.height,
216
+ }),
217
+ );
218
+ observer.observe(node);
219
+ return () => observer.disconnect();
220
+ }, [wallRef]);
221
+
222
+ const geometry = box ? stripGeometry(box.width, box.height) : null;
223
+ const slots: PosterWallTile[] = geometry
224
+ ? Array.from(
225
+ { length: geometry.slotCount },
226
+ (_, index) => pictures[index] ?? {},
227
+ )
228
+ : [];
229
+
230
+ return (
231
+ <div
232
+ ref={wallRef}
233
+ className={cn("absolute inset-0 overflow-hidden", className)}
234
+ style={{ backgroundColor: POSTER_WALL_BASE }}
235
+ >
236
+ <style>{`
237
+ @keyframes ${POSTER_WALL_SWEEP_KEYFRAMES_ID} {
238
+ 0% { transform: translateX(-30%); }
239
+ 100% { transform: translateX(30%); }
240
+ }
241
+ `}</style>
242
+
243
+ {geometry ? (
244
+ <div
245
+ className="motion-image-enter absolute inset-0 grid grid-flow-col grid-rows-2 gap-[2px] [direction:rtl]"
246
+ style={{ gridAutoColumns: `${geometry.tileWidth}px` }}
247
+ aria-hidden
248
+ >
249
+ {slots.map((tile, index) =>
250
+ tile.src ? (
251
+ // eslint-disable-next-line @next/next/no-img-element
252
+ <img
253
+ key={index}
254
+ src={tile.src}
255
+ alt=""
256
+ loading="lazy"
257
+ decoding="async"
258
+ className="h-full w-full object-cover"
259
+ />
260
+ ) : (
261
+ <div key={index} className={PLACEHOLDER_TILE_CLASS} />
262
+ ),
263
+ )}
264
+ </div>
265
+ ) : null}
266
+
267
+ {/* Peak 0.22, not the masthead's 0.34: that was tuned for a half-opacity
268
+ wall and would wash out posters shown at full clarity. */}
269
+ <div
270
+ className="absolute -inset-[20%]"
271
+ style={{
272
+ background:
273
+ "radial-gradient(circle at 30% 50%, oklch(1 0 0 / 0.22) 0%, transparent 42%)",
274
+ animationName: POSTER_WALL_SWEEP_KEYFRAMES_ID,
275
+ animationDuration: "18s",
276
+ animationTimingFunction: "linear",
277
+ animationIterationCount: "infinite",
278
+ animationPlayState: isSweepPaused ? "paused" : "running",
279
+ }}
280
+ aria-hidden
281
+ />
282
+
283
+ {/* Two veils, one per breakpoint — a gradient swap, not DOM weight. */}
284
+ <div
285
+ className="absolute inset-0 lg:hidden"
286
+ style={{ background: STRIP_VEIL }}
287
+ aria-hidden
288
+ />
289
+ <div
290
+ className="absolute inset-0 hidden lg:block"
291
+ style={{ background: STRIP_VEIL_WIDE }}
292
+ aria-hidden
293
+ />
294
+
295
+ {children ? (
296
+ <div className="relative h-full min-w-0 text-white">{children}</div>
297
+ ) : null}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ function PosterWallMasthead({
303
+ tiles,
304
+ variant,
305
+ sweep,
306
+ children,
307
+ className,
308
+ }: {
309
+ tiles?: PosterWallTile[];
310
+ variant: "default" | "compact";
311
+ sweep: boolean;
312
+ children?: React.ReactNode;
313
+ className?: string;
314
+ }) {
97
315
  const isCompact = variant === "compact";
98
316
  const maxCols = isCompact ? 6 : 12;
99
317
  const maxSlots = isCompact
@@ -107,7 +325,7 @@ export function PosterWall({
107
325
 
108
326
  const prefersReducedMotion = usePrefersReducedMotion();
109
327
  const [wallRef, inView] = useInViewport<HTMLDivElement>();
110
- const isSweepPaused = prefersReducedMotion || !inView;
328
+ const isSweepPaused = !sweep || prefersReducedMotion || !inView;
111
329
 
112
330
  // Measured on the client; the server assumes a phone or a wide desktop
113
331
  // column, which is what those two variants are for.
@@ -172,10 +390,7 @@ export function PosterWall({
172
390
  className="h-full w-full object-cover"
173
391
  />
174
392
  ) : (
175
- <div
176
- key={index}
177
- className="h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]"
178
- />
393
+ <div key={index} className={PLACEHOLDER_TILE_CLASS} />
179
394
  ),
180
395
  )}
181
396
  </div>
@@ -223,16 +438,49 @@ export function PosterWall({
223
438
  }
224
439
 
225
440
  export type PosterWallSkeletonProps = {
226
- /** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
227
- variant?: "default" | "compact";
441
+ /** Same meaning as on `PosterWall`; `strip` fills the caller's box. */
442
+ variant?: PosterWallVariant;
228
443
  className?: string;
229
444
  };
230
445
 
446
+ /** Twelve tiles: a 343×150 strip at rest. The count is cosmetic — the box is the caller's. */
447
+ const POSTER_WALL_STRIP_SKELETON_TILE_COUNT = 12;
448
+
231
449
  /** The tile grid at rest — the sweeping light never renders in the loading state. */
232
450
  export function PosterWallSkeleton({
233
451
  variant = "default",
234
452
  className,
235
453
  }: PosterWallSkeletonProps) {
454
+ if (variant === "strip") {
455
+ return (
456
+ <div
457
+ aria-hidden="true"
458
+ className={cn("absolute inset-0 overflow-hidden", className)}
459
+ style={{ backgroundColor: POSTER_WALL_BASE }}
460
+ >
461
+ <div className="absolute inset-0 grid auto-cols-fr grid-flow-col grid-rows-2 gap-[2px] [direction:rtl]">
462
+ {Array.from(
463
+ { length: POSTER_WALL_STRIP_SKELETON_TILE_COUNT },
464
+ (_, index) => (
465
+ <Skeleton
466
+ key={index}
467
+ className="h-full w-full rounded-none bg-white/10"
468
+ />
469
+ ),
470
+ )}
471
+ </div>
472
+ <div
473
+ className="absolute inset-0 lg:hidden"
474
+ style={{ background: STRIP_VEIL }}
475
+ />
476
+ <div
477
+ className="absolute inset-0 hidden lg:block"
478
+ style={{ background: STRIP_VEIL_WIDE }}
479
+ />
480
+ </div>
481
+ );
482
+ }
483
+
236
484
  const isCompact = variant === "compact";
237
485
  const tileCount = isCompact
238
486
  ? POSTER_WALL_COMPACT_TILE_COUNT
@@ -1,6 +1,28 @@
1
+ import * as React from "react";
1
2
  import type { Meta, StoryObj } from "@storybook/react";
2
3
  import { CalendarClock, LayoutGrid, List, SlidersHorizontal } from "lucide-react";
3
- import { SegmentedNav } from "./segmented-nav";
4
+ import { SegmentedNav, type SegmentedNavLinkProps } from "./segmented-nav";
5
+ import { MotionUtensils, MotionWine } from "./motion-icon-set";
6
+
7
+ /**
8
+ * Module-scoped on purpose: a `linkComponent` created inside `render` is a new
9
+ * element type on every story re-render, and React would remount every
10
+ * segment — killing the pointer-down gesture the story exists to show.
11
+ */
12
+ function PreventNavLink({ href, children, onClick, ...props }: SegmentedNavLinkProps) {
13
+ return (
14
+ <a
15
+ href={href}
16
+ onClick={(e) => {
17
+ e.preventDefault();
18
+ onClick?.();
19
+ }}
20
+ {...props}
21
+ >
22
+ {children}
23
+ </a>
24
+ );
25
+ }
4
26
 
5
27
  const meta = {
6
28
  title: "Components/SegmentedNav",
@@ -86,6 +108,55 @@ export const InAControlRow: Story = {
86
108
  ),
87
109
  };
88
110
 
111
+ /**
112
+ * CHARACTER-ANIMATED SEGMENTS — the menu hub's Food / Drinks toggle.
113
+ *
114
+ * `icon` as a function receives the segment's `active` state and a
115
+ * `replayToken` that bumps on pointer down, so the utensils uncross and the
116
+ * glass pours on the finger, before the route changes. Both glyphs HOLD their
117
+ * pose while selected, so the active side is readable from the silhouette and
118
+ * not only from the tint. The story swaps `activeId` on click to stand in for
119
+ * navigation.
120
+ */
121
+ export const WithMotionIcons: Story = {
122
+ args: {
123
+ items: [],
124
+ activeId: "food",
125
+ label: "Menu views",
126
+ },
127
+ render: (args) => {
128
+ const [activeId, setActiveId] = React.useState(args.activeId);
129
+ return (
130
+ <div className="w-[358px]">
131
+ <SegmentedNav
132
+ {...args}
133
+ activeId={activeId}
134
+ onSelect={setActiveId}
135
+ linkComponent={PreventNavLink}
136
+ items={[
137
+ {
138
+ id: "food",
139
+ href: "/menu",
140
+ label: "Food",
141
+ icon: ({ active, replayToken }) => (
142
+ <MotionUtensils active={active} replayToken={replayToken} className={ICON} />
143
+ ),
144
+ },
145
+ {
146
+ id: "drinks",
147
+ href: "/menu/drinks",
148
+ label: "Drinks",
149
+ icon: ({ active, replayToken }) => (
150
+ <MotionWine active={active} replayToken={replayToken} className={ICON} />
151
+ ),
152
+ },
153
+ ]}
154
+ />
155
+ </div>
156
+ );
157
+ },
158
+ };
159
+
89
160
  /** Labels alone, no icons. */
90
161
  export const WithoutIcons: Story = {
91
162
  args: {
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import {
4
+ SegmentedNav,
5
+ type SegmentedNavIconState,
6
+ type SegmentedNavLinkProps,
7
+ } from "./segmented-nav";
8
+
9
+ const items = [
10
+ { id: "food", href: "/menu", label: "Food" },
11
+ { id: "drinks", href: "/menu/drinks", label: "Drinks" },
12
+ ];
13
+
14
+ /** A real `<a href>` click makes jsdom log "Not implemented: navigation"; this one stays put. */
15
+ function StubLink({ href, children, onClick, ...props }: SegmentedNavLinkProps) {
16
+ return (
17
+ <a
18
+ href={href}
19
+ onClick={(e) => {
20
+ e.preventDefault();
21
+ onClick?.();
22
+ }}
23
+ {...props}
24
+ >
25
+ {children}
26
+ </a>
27
+ );
28
+ }
29
+
30
+ describe("SegmentedNav", () => {
31
+ it("marks the active segment as the current page", () => {
32
+ render(<SegmentedNav items={items} activeId="drinks" label="Menu views" />);
33
+ expect(screen.getByRole("link", { name: "Drinks" })).toHaveAttribute("aria-current", "page");
34
+ expect(screen.getByRole("link", { name: "Food" })).not.toHaveAttribute("aria-current");
35
+ });
36
+
37
+ it("renders a static icon as given", () => {
38
+ render(
39
+ <SegmentedNav
40
+ items={[{ ...items[0], icon: <span data-testid="static-icon" /> }]}
41
+ activeId="food"
42
+ label="Menu views"
43
+ />
44
+ );
45
+ expect(screen.getByTestId("static-icon")).toBeInTheDocument();
46
+ });
47
+
48
+ it("hands a function icon its segment's state and bumps the token on pointer down, not on click", () => {
49
+ const foodIcon = vi.fn((state: SegmentedNavIconState) => (
50
+ <span data-testid="food-icon" data-active={state.active} data-token={state.replayToken} />
51
+ ));
52
+ const drinksIcon = vi.fn((state: SegmentedNavIconState) => (
53
+ <span data-testid="drinks-icon" data-token={state.replayToken} />
54
+ ));
55
+ render(
56
+ <SegmentedNav
57
+ items={[
58
+ { ...items[0], icon: foodIcon },
59
+ { ...items[1], icon: drinksIcon },
60
+ ]}
61
+ activeId="food"
62
+ label="Menu views"
63
+ linkComponent={StubLink}
64
+ />
65
+ );
66
+
67
+ expect(screen.getByTestId("food-icon")).toHaveAttribute("data-active", "true");
68
+ expect(screen.getByTestId("food-icon")).toHaveAttribute("data-token", "0");
69
+
70
+ // The finger lands on Drinks: its glyph performs before any navigation.
71
+ const drinks = screen.getByRole("link", { name: "Drinks" });
72
+ fireEvent.pointerDown(drinks);
73
+ expect(screen.getByTestId("drinks-icon")).toHaveAttribute("data-token", "1");
74
+ // The neighbour is untouched.
75
+ expect(screen.getByTestId("food-icon")).toHaveAttribute("data-token", "0");
76
+
77
+ // A click alone is not a performance — the token answers the pointer only.
78
+ fireEvent.click(drinks);
79
+ expect(screen.getByTestId("drinks-icon")).toHaveAttribute("data-token", "1");
80
+ });
81
+
82
+ it("fires onSelect with the segment id on click", () => {
83
+ const onSelect = vi.fn();
84
+ render(
85
+ <SegmentedNav
86
+ items={items}
87
+ activeId="food"
88
+ label="Menu views"
89
+ onSelect={onSelect}
90
+ linkComponent={StubLink}
91
+ />
92
+ );
93
+ fireEvent.click(screen.getByRole("link", { name: "Drinks" }));
94
+ expect(onSelect).toHaveBeenCalledWith("drinks");
95
+ });
96
+ });