@snowcone-app/ui 0.1.43 → 0.2.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 (196) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +18 -4
  3. package/dist/index.cjs +5 -2
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -5
  8. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  9. package/src/components/LoadingOverlayPrism.tsx +251 -0
  10. package/src/composed/AddToCart.tsx +229 -0
  11. package/src/composed/ArtAlignment.tsx +703 -0
  12. package/src/composed/ArtSelector.tsx +290 -0
  13. package/src/composed/ArtworkCustomizer.tsx +212 -0
  14. package/src/composed/CanvasEditor.tsx +79 -0
  15. package/src/composed/ColorPicker.tsx +111 -0
  16. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  17. package/src/composed/HeroProductImage.tsx +1079 -0
  18. package/src/composed/Lightbox.index.ts +2 -0
  19. package/src/composed/Lightbox.tsx +230 -0
  20. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  21. package/src/composed/PlacementTabs.tsx +179 -0
  22. package/src/composed/ProductCard.tsx +298 -0
  23. package/src/composed/ProductGallery.tsx +54 -0
  24. package/src/composed/ProductImage.tsx +129 -0
  25. package/src/composed/ProductList.tsx +147 -0
  26. package/src/composed/ProductOptions.tsx +305 -0
  27. package/src/composed/RealtimeMockup.tsx +121 -0
  28. package/src/composed/TileCount.tsx +348 -0
  29. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  30. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  31. package/src/composed/carousels/index.ts +11 -0
  32. package/src/composed/carousels/types.ts +58 -0
  33. package/src/composed/grids/MasonryGrid.tsx +238 -0
  34. package/src/composed/grids/index.ts +9 -0
  35. package/src/composed/search/CurrentRefinements.tsx +80 -0
  36. package/src/composed/search/Filters.tsx +49 -0
  37. package/src/composed/search/FiltersButton.tsx +57 -0
  38. package/src/composed/search/FiltersDrawer.tsx +375 -0
  39. package/src/composed/search/ProductGrid.tsx +118 -0
  40. package/src/composed/search/ProductHit.tsx +56 -0
  41. package/src/composed/search/SearchBox.tsx +109 -0
  42. package/src/composed/search/SearchProvider.tsx +136 -0
  43. package/src/composed/search/facetConfig.ts +16 -0
  44. package/src/composed/search/index.ts +22 -0
  45. package/src/composed/search/meilisearchAdapter.ts +20 -0
  46. package/src/composed/search/types.ts +22 -0
  47. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  48. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  49. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  50. package/src/composed/zoom/index.ts +12 -0
  51. package/src/composed/zoom/types.ts +12 -0
  52. package/src/design-system/ColorPalette.tsx +126 -0
  53. package/src/design-system/ColorSwatch.tsx +49 -0
  54. package/src/design-system/DesignSystemPage.tsx +130 -0
  55. package/src/design-system/ThemeSwitcher.tsx +181 -0
  56. package/src/design-system/TypographyScale.tsx +106 -0
  57. package/src/design-system/index.ts +5 -0
  58. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  59. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  60. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  61. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  62. package/src/hooks/useBrand.ts +41 -0
  63. package/src/hooks/useCanvasContext.ts +127 -0
  64. package/src/hooks/useDeviceDetection.ts +64 -0
  65. package/src/hooks/useFocusTrap.ts +70 -0
  66. package/src/hooks/useImagePreloader.ts +268 -0
  67. package/src/hooks/useImageTransition.ts +608 -0
  68. package/src/hooks/usePlacementsProcessor.ts +74 -0
  69. package/src/hooks/useProductGallery.ts +193 -0
  70. package/src/hooks/useProductPage.ts +467 -0
  71. package/src/hooks/useRenderGuard.ts +96 -0
  72. package/src/hooks/useScrollDirection.ts +196 -0
  73. package/src/hooks/viewport/index.ts +25 -0
  74. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  75. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  76. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  77. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  78. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  79. package/src/hooks/visibility/index.ts +15 -0
  80. package/src/hooks/visibility/observerPool.ts +150 -0
  81. package/src/index.ts +240 -0
  82. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  83. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  84. package/src/layouts/hero-zoom/index.ts +30 -0
  85. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  86. package/src/layouts/hero-zoom/types.ts +113 -0
  87. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  88. package/src/layouts/index.ts +9 -0
  89. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  90. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  91. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  92. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  93. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  94. package/src/layouts/pdp/index.ts +40 -0
  95. package/src/lib/env.ts +15 -0
  96. package/src/lib/locale.ts +167 -0
  97. package/src/lib/router.tsx +46 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/lightbox/README.md +77 -0
  100. package/src/next/index.tsx +26 -0
  101. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  102. package/src/patterns/Product.tsx +850 -0
  103. package/src/patterns/ProductPageProvider.tsx +224 -0
  104. package/src/patterns/RealtimeProvider.tsx +1162 -0
  105. package/src/patterns/ShopProvider.tsx +603 -0
  106. package/src/personalization/PersonalizationBridge.tsx +235 -0
  107. package/src/personalization/PersonalizationContext.ts +29 -0
  108. package/src/personalization/PersonalizationInputs.tsx +110 -0
  109. package/src/personalization/PersonalizationProvider.tsx +407 -0
  110. package/src/personalization/canvas-stub.d.ts +22 -0
  111. package/src/personalization/index.ts +43 -0
  112. package/src/personalization/types.ts +48 -0
  113. package/src/personalization/usePersonalization.ts +32 -0
  114. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  115. package/src/personalization/utils.ts +59 -0
  116. package/src/primitives/BrandLogo.tsx +65 -0
  117. package/src/primitives/BrandName.tsx +51 -0
  118. package/src/primitives/Button.tsx +123 -0
  119. package/src/primitives/ColorSwatch.tsx +221 -0
  120. package/src/primitives/DragHintAnimation.tsx +190 -0
  121. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  122. package/src/primitives/FloatingActionGroup.tsx +176 -0
  123. package/src/primitives/ProductPrice.tsx +171 -0
  124. package/src/primitives/ProgressiveBlur.tsx +295 -0
  125. package/src/primitives/ThemeToggle.tsx +125 -0
  126. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  127. package/src/primitives/accordion.tsx +280 -0
  128. package/src/primitives/badge.tsx +137 -0
  129. package/src/primitives/card.tsx +61 -0
  130. package/src/primitives/checkbox.tsx +56 -0
  131. package/src/primitives/collapsible.tsx +51 -0
  132. package/src/primitives/drawer.tsx +828 -0
  133. package/src/primitives/dropdown-menu.tsx +197 -0
  134. package/src/primitives/fieldset.tsx +73 -0
  135. package/src/primitives/index.ts +138 -0
  136. package/src/primitives/input.tsx +91 -0
  137. package/src/primitives/kbd.tsx +130 -0
  138. package/src/primitives/label.tsx +20 -0
  139. package/src/primitives/link.tsx +182 -0
  140. package/src/primitives/popover.tsx +80 -0
  141. package/src/primitives/radio-group.tsx +79 -0
  142. package/src/primitives/scroll-fade.tsx +159 -0
  143. package/src/primitives/select.tsx +170 -0
  144. package/src/primitives/separator.tsx +25 -0
  145. package/src/primitives/slider.tsx +221 -0
  146. package/src/primitives/spinner.tsx +72 -0
  147. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  148. package/src/primitives/stories/Badge.stories.tsx +221 -0
  149. package/src/primitives/stories/Button.stories.tsx +185 -0
  150. package/src/primitives/stories/Card.stories.tsx +171 -0
  151. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  152. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  153. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  154. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  155. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  156. package/src/primitives/stories/Input.stories.tsx +172 -0
  157. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  158. package/src/primitives/stories/Label.stories.tsx +98 -0
  159. package/src/primitives/stories/Link.stories.tsx +260 -0
  160. package/src/primitives/stories/Popover.stories.tsx +178 -0
  161. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  162. package/src/primitives/stories/Select.stories.tsx +222 -0
  163. package/src/primitives/stories/Separator.stories.tsx +134 -0
  164. package/src/primitives/stories/Slider.stories.tsx +203 -0
  165. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  166. package/src/primitives/stories/Surface.stories.tsx +257 -0
  167. package/src/primitives/stories/Switch.stories.tsx +131 -0
  168. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  169. package/src/primitives/stories/TextField.stories.tsx +139 -0
  170. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  171. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  172. package/src/primitives/surface.tsx +86 -0
  173. package/src/primitives/switch.tsx +35 -0
  174. package/src/primitives/tabs.tsx +206 -0
  175. package/src/primitives/text-field.tsx +84 -0
  176. package/src/primitives/textarea.tsx +50 -0
  177. package/src/primitives/tooltip.tsx +58 -0
  178. package/src/services/CanvasExportService.ts +518 -0
  179. package/src/styles/base.css +380 -0
  180. package/src/styles/defaults.css +280 -0
  181. package/src/styles/globals.css +1242 -0
  182. package/src/styles/index.css +17 -0
  183. package/src/styles/ne-themes.css +4740 -0
  184. package/src/styles/tailwind.css +11 -0
  185. package/src/styles/tokens.css +117 -0
  186. package/src/styles/utilities.css +188 -0
  187. package/src/themes/apply-theme.ts +449 -0
  188. package/src/themes/getThemeStyles.ts +454 -0
  189. package/src/themes/index.ts +48 -0
  190. package/src/themes/oklch-theme.ts +283 -0
  191. package/src/themes/presets.ts +989 -0
  192. package/src/themes/types.ts +386 -0
  193. package/src/themes/useTheme.tsx +450 -0
  194. package/src/utils/dev-warnings.ts +161 -0
  195. package/src/utils/devWarnings.ts +153 -0
  196. package/dist/styles.css +0 -1
@@ -0,0 +1,295 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgressiveBlur - Layered backdrop-filter blur effect
5
+ *
6
+ * Creates a smooth progressive blur using multiple stacked layers with
7
+ * increasing blur values. Perfect for sticky headers, footers, or any
8
+ * UI element where content should gracefully blur as it scrolls underneath.
9
+ *
10
+ * @example
11
+ * // In a sticky header - blur content scrolling up from below
12
+ * <header className="sticky top-0">
13
+ * <nav>...</nav>
14
+ * <ProgressiveBlur position="bottom" height="40px" />
15
+ * </header>
16
+ *
17
+ * @example
18
+ * // At bottom of viewport - blur content scrolling down
19
+ * <ProgressiveBlur position="top" height="60px" />
20
+ */
21
+
22
+ import React from "react";
23
+
24
+ function cn(...classes: (string | undefined | null | false)[]) {
25
+ return classes.filter(Boolean).join(" ");
26
+ }
27
+
28
+ export interface ProgressiveBlurProps {
29
+ /** Additional CSS classes */
30
+ className?: string;
31
+ /** Height of the blur zone */
32
+ height?: string;
33
+ /** Position of the blur effect */
34
+ position?: "top" | "bottom" | "both";
35
+ /** Custom blur levels (default: exponential scale from 0.5px to 64px) */
36
+ blurLevels?: number[];
37
+ children?: React.ReactNode;
38
+ }
39
+
40
+ /**
41
+ * ProgressiveBlur creates a layered blur effect using multiple stacked divs
42
+ * with increasing backdrop-filter blur values and gradient masks.
43
+ *
44
+ * The default blur levels [0.5, 1, 2, 4, 8, 16, 32, 64] create an exponential
45
+ * progression that looks smooth and natural.
46
+ */
47
+ export function ProgressiveBlur({
48
+ className,
49
+ height = "30%",
50
+ position = "bottom",
51
+ blurLevels = [0.5, 1, 2, 4, 8, 16, 32, 64],
52
+ }: ProgressiveBlurProps) {
53
+ // Create array with length equal to blurLevels.length - 2 (for before/after pseudo elements)
54
+ const divElements = Array(blurLevels.length - 2).fill(null);
55
+
56
+ return (
57
+ <div
58
+ className={cn(
59
+ "gradient-blur pointer-events-none absolute inset-x-0 z-10",
60
+ className,
61
+ position === "top"
62
+ ? "top-0"
63
+ : position === "bottom"
64
+ ? "bottom-0"
65
+ : "inset-y-0"
66
+ )}
67
+ style={{
68
+ height: position === "both" ? "100%" : height,
69
+ }}
70
+ >
71
+ {/* First blur layer (pseudo element) */}
72
+ <div
73
+ className="absolute inset-0"
74
+ style={{
75
+ zIndex: 1,
76
+ backdropFilter: `blur(${blurLevels[0]}px)`,
77
+ WebkitBackdropFilter: `blur(${blurLevels[0]}px)`,
78
+ maskImage:
79
+ position === "bottom"
80
+ ? `linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)`
81
+ : position === "top"
82
+ ? `linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)`
83
+ : `linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 5%, rgba(0,0,0,1) 95%, rgba(0,0,0,0) 100%)`,
84
+ WebkitMaskImage:
85
+ position === "bottom"
86
+ ? `linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)`
87
+ : position === "top"
88
+ ? `linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)`
89
+ : `linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 5%, rgba(0,0,0,1) 95%, rgba(0,0,0,0) 100%)`,
90
+ }}
91
+ />
92
+
93
+ {/* Middle blur layers */}
94
+ {divElements.map((_, index) => {
95
+ const blurIndex = index + 1;
96
+ const startPercent = blurIndex * 12.5;
97
+ const midPercent = (blurIndex + 1) * 12.5;
98
+ const endPercent = (blurIndex + 2) * 12.5;
99
+
100
+ const maskGradient =
101
+ position === "bottom"
102
+ ? `linear-gradient(to bottom, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${endPercent + 12.5}%)`
103
+ : position === "top"
104
+ ? `linear-gradient(to top, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${endPercent + 12.5}%)`
105
+ : `linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 5%, rgba(0,0,0,1) 95%, rgba(0,0,0,0) 100%)`;
106
+
107
+ return (
108
+ <div
109
+ key={`blur-${index}`}
110
+ className="absolute inset-0"
111
+ style={{
112
+ zIndex: index + 2,
113
+ backdropFilter: `blur(${blurLevels[blurIndex]}px)`,
114
+ WebkitBackdropFilter: `blur(${blurLevels[blurIndex]}px)`,
115
+ maskImage: maskGradient,
116
+ WebkitMaskImage: maskGradient,
117
+ }}
118
+ />
119
+ );
120
+ })}
121
+
122
+ {/* Last blur layer (pseudo element) */}
123
+ <div
124
+ className="absolute inset-0"
125
+ style={{
126
+ zIndex: blurLevels.length,
127
+ backdropFilter: `blur(${blurLevels[blurLevels.length - 1]}px)`,
128
+ WebkitBackdropFilter: `blur(${blurLevels[blurLevels.length - 1]}px)`,
129
+ maskImage:
130
+ position === "bottom"
131
+ ? `linear-gradient(to bottom, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)`
132
+ : position === "top"
133
+ ? `linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)`
134
+ : `linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 5%, rgba(0,0,0,1) 95%, rgba(0,0,0,0) 100%)`,
135
+ WebkitMaskImage:
136
+ position === "bottom"
137
+ ? `linear-gradient(to bottom, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)`
138
+ : position === "top"
139
+ ? `linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)`
140
+ : `linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 5%, rgba(0,0,0,1) 95%, rgba(0,0,0,0) 100%)`,
141
+ }}
142
+ />
143
+ </div>
144
+ );
145
+ }
146
+
147
+ // ============================================================================
148
+ // Horizontal Blur Variants
149
+ // ============================================================================
150
+
151
+ export interface HorizontalProgressiveBlurProps {
152
+ /** Width of the blur zone in pixels */
153
+ width: number;
154
+ /** Additional CSS classes */
155
+ className?: string;
156
+ /** Custom blur levels (default: smooth transition for bidirectional effect) */
157
+ blurLevels?: number[];
158
+ }
159
+
160
+ /**
161
+ * HorizontalProgressiveBlur - Creates a bidirectional horizontal progressive blur effect.
162
+ *
163
+ * Blurs from both edges toward the center, creating maximum blur at the center (the seam)
164
+ * and fading to clear on both the left and right sides. Perfect for seamless image transitions.
165
+ *
166
+ * @example
167
+ * ```tsx
168
+ * // Center seam blur between two images
169
+ * <div className="relative">
170
+ * <img src="/left.jpg" />
171
+ * <HorizontalProgressiveBlur width={100} className="left-1/2 -translate-x-1/2" />
172
+ * <img src="/right.jpg" />
173
+ * </div>
174
+ * ```
175
+ */
176
+ export function HorizontalProgressiveBlur({
177
+ width,
178
+ className = "",
179
+ blurLevels = [0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 9, 13, 18, 24],
180
+ }: HorizontalProgressiveBlurProps) {
181
+ const numLayers = blurLevels.length;
182
+
183
+ return (
184
+ <div
185
+ className={cn(
186
+ "absolute top-0 bottom-0 pointer-events-none",
187
+ className
188
+ )}
189
+ style={{ width }}
190
+ >
191
+ {blurLevels.map((blur, index) => {
192
+ // Bidirectional: blur increases from edges (0%, 100%) toward center (50%)
193
+ // Each layer covers a band that gets narrower as blur increases
194
+ const layerRatio = index / (numLayers - 1); // 0 to 1
195
+ const bandStart = layerRatio * 47; // 0% to 47%
196
+ const bandEnd = 100 - bandStart; // 100% to 53%
197
+
198
+ // Smoother gradient transitions
199
+ const fadeWidth = 3;
200
+ const maskGradient = `linear-gradient(to right,
201
+ rgba(0,0,0,0) 0%,
202
+ rgba(0,0,0,0) ${Math.max(0, bandStart - fadeWidth)}%,
203
+ rgba(0,0,0,1) ${bandStart + fadeWidth}%,
204
+ rgba(0,0,0,1) ${bandEnd - fadeWidth}%,
205
+ rgba(0,0,0,0) ${Math.min(100, bandEnd + fadeWidth)}%,
206
+ rgba(0,0,0,0) 100%
207
+ )`;
208
+
209
+ return (
210
+ <div
211
+ key={`blur-layer-${index}`}
212
+ className="absolute inset-0"
213
+ style={{
214
+ zIndex: index + 1,
215
+ backdropFilter: `blur(${blur}px)`,
216
+ WebkitBackdropFilter: `blur(${blur}px)`,
217
+ maskImage: maskGradient,
218
+ WebkitMaskImage: maskGradient,
219
+ }}
220
+ />
221
+ );
222
+ })}
223
+ </div>
224
+ );
225
+ }
226
+
227
+ export interface RightToLeftProgressiveBlurProps {
228
+ /** Width of the blur zone in pixels */
229
+ width: number;
230
+ /** Additional CSS classes */
231
+ className?: string;
232
+ /** Custom blur levels (default: exponential increase for unidirectional effect) */
233
+ blurLevels?: number[];
234
+ }
235
+
236
+ /**
237
+ * RightToLeftProgressiveBlur - Creates a unidirectional progressive blur effect.
238
+ *
239
+ * Blur increases from right (clear) to left (max blur). Anchored to the right edge,
240
+ * blur builds up toward the left. Useful for fading content into a blurred edge.
241
+ *
242
+ * @example
243
+ * ```tsx
244
+ * // Blur left edge of content
245
+ * <div className="relative">
246
+ * <RightToLeftProgressiveBlur width={80} className="left-0" />
247
+ * <Content />
248
+ * </div>
249
+ * ```
250
+ */
251
+ export function RightToLeftProgressiveBlur({
252
+ width,
253
+ className = "",
254
+ blurLevels = [1, 2, 4, 8, 16, 24, 32, 40],
255
+ }: RightToLeftProgressiveBlurProps) {
256
+ const numLayers = blurLevels.length;
257
+
258
+ return (
259
+ <div
260
+ className={cn(
261
+ "absolute top-0 bottom-0 pointer-events-none",
262
+ className
263
+ )}
264
+ style={{ width }}
265
+ >
266
+ {blurLevels.map((blur, index) => {
267
+ // Each layer starts from the right and extends further left
268
+ // Layer 0 (lowest blur): covers 0% - 12.5%
269
+ // Layer N (highest blur): covers 0% - 100%
270
+ const endPercent = ((index + 1) / numLayers) * 100;
271
+
272
+ const maskGradient = `linear-gradient(to right,
273
+ rgba(0,0,0,1) 0%,
274
+ rgba(0,0,0,1) ${endPercent}%,
275
+ rgba(0,0,0,0) ${Math.min(100, endPercent + 5)}%,
276
+ rgba(0,0,0,0) 100%
277
+ )`;
278
+
279
+ return (
280
+ <div
281
+ key={`blur-layer-${index}`}
282
+ className="absolute inset-0"
283
+ style={{
284
+ zIndex: index + 1,
285
+ backdropFilter: `blur(${blur}px)`,
286
+ WebkitBackdropFilter: `blur(${blur}px)`,
287
+ maskImage: maskGradient,
288
+ WebkitMaskImage: maskGradient,
289
+ }}
290
+ />
291
+ );
292
+ })}
293
+ </div>
294
+ );
295
+ }
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ export interface ThemeToggleProps {
6
+ isDark?: boolean;
7
+ onToggle?: () => void;
8
+ className?: string;
9
+ position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
10
+ disabled?: boolean;
11
+ }
12
+
13
+ /**
14
+ * ThemeToggle - A primitive floating button for toggling between light and dark themes
15
+ *
16
+ * A fixed-position button that displays a sun/moon icon based on the current theme.
17
+ * Commonly used for implementing dark mode toggles in applications.
18
+ *
19
+ * Features:
20
+ * - Fixed positioning with configurable corners
21
+ * - Animated icon transitions
22
+ * - Accessible with ARIA labels
23
+ * - Responsive hover and active states
24
+ * - Built-in disabled state
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * const [isDark, setIsDark] = useState(false);
29
+ *
30
+ * <ThemeToggle
31
+ * isDark={isDark}
32
+ * onToggle={() => setIsDark(!isDark)}
33
+ * position="bottom-right"
34
+ * />
35
+ * ```
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * // With Next.js theme provider
40
+ * import { useTheme } from 'next-themes';
41
+ *
42
+ * function MyApp() {
43
+ * const { theme, setTheme } = useTheme();
44
+ * return (
45
+ * <ThemeToggle
46
+ * isDark={theme === 'dark'}
47
+ * onToggle={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
48
+ * />
49
+ * );
50
+ * }
51
+ * ```
52
+ *
53
+ * @param isDark - Whether dark mode is currently active (default: false)
54
+ * @param onToggle - Callback function when toggle is clicked
55
+ * @param className - Additional CSS classes for customization
56
+ * @param position - Corner position (default: "bottom-right")
57
+ * @param disabled - Disable the toggle button (default: false)
58
+ */
59
+ export function ThemeToggle({
60
+ isDark = false,
61
+ onToggle,
62
+ className = "",
63
+ position = "bottom-right",
64
+ disabled = false,
65
+ }: ThemeToggleProps) {
66
+ const positionClasses = {
67
+ "bottom-right": "bottom-6 right-6",
68
+ "bottom-left": "bottom-6 left-6",
69
+ "top-right": "top-6 right-6",
70
+ "top-left": "top-6 left-6",
71
+ }[position];
72
+
73
+ const handleClick = () => {
74
+ if (!disabled && onToggle) {
75
+ onToggle();
76
+ }
77
+ };
78
+
79
+ return (
80
+ <button
81
+ type="button"
82
+ onClick={handleClick}
83
+ disabled={disabled}
84
+ className={`
85
+ fixed z-50 w-12 h-12 rounded-full shadow-lg transition-all duration-200
86
+ flex items-center justify-center
87
+ bg-background text-foreground border border-border hover:bg-muted
88
+ ${positionClasses}
89
+ ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95 cursor-pointer'}
90
+ ${className}
91
+ `}
92
+ aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
93
+ title={`Switch to ${isDark ? 'light' : 'dark'} mode`}
94
+ >
95
+ {isDark ? (
96
+ // Sun icon for light mode
97
+ <svg
98
+ className="w-5 h-5"
99
+ fill="none"
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ strokeWidth="2"
103
+ viewBox="0 0 24 24"
104
+ stroke="currentColor"
105
+ >
106
+ <circle cx="12" cy="12" r="5" />
107
+ <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
108
+ </svg>
109
+ ) : (
110
+ // Moon icon for dark mode
111
+ <svg
112
+ className="w-5 h-5"
113
+ fill="none"
114
+ strokeLinecap="round"
115
+ strokeLinejoin="round"
116
+ strokeWidth="2"
117
+ viewBox="0 0 24 24"
118
+ stroke="currentColor"
119
+ >
120
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
121
+ </svg>
122
+ )}
123
+ </button>
124
+ );
125
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * ADR-0064 §3 Layer 3 — story-coverage gate.
3
+ *
4
+ * Every file in `src/primitives/*.tsx` must have a matching
5
+ * `src/primitives/stories/*.stories.tsx`. Fails CI on net-new
6
+ * primitives shipped without stories.
7
+ *
8
+ * Naming heuristic:
9
+ * - `Button.tsx` → `Button.stories.tsx` (PascalCase)
10
+ * - `accordion.tsx` → `Accordion.stories.tsx` (lowercase → PascalCase)
11
+ * - `dropdown-menu.tsx` → `DropdownMenu.stories.tsx` (kebab → PascalCase)
12
+ *
13
+ * Allowlist (`PRIMITIVES_WITHOUT_STORIES`) is for files that are NOT
14
+ * actual primitives (e.g., utility components, animation helpers,
15
+ * brand-name components). Each entry needs a one-line comment explaining
16
+ * why it doesn't need a story.
17
+ */
18
+
19
+ import { describe, it, expect } from "vitest";
20
+ import { promises as fs } from "fs";
21
+ import { join } from "path";
22
+
23
+ const PRIMITIVES_DIR = join(__dirname, "..");
24
+ const STORIES_DIR = join(__dirname, "..", "stories");
25
+
26
+ /**
27
+ * Files in primitives/ that don't need a story. Each entry MUST cite
28
+ * a reason; reviewers reject without one.
29
+ */
30
+ const PRIMITIVES_WITHOUT_STORIES = new Map<string, string>([
31
+ ["index", "barrel export, not a primitive"],
32
+ ["BrandLogo", "wraps next/image with brand-config props; no behavior to story"],
33
+ ["BrandName", "thin <span> reading from brand-config; covered by AppShell story when one exists"],
34
+ ["DragHintAnimation", "decorative animation; no a11y-relevant state surface"],
35
+ ["EdgeSwipeGuards", "iOS gesture-suppression overlay; no visible UI"],
36
+ ["FloatingActionGroup", "layout container; tested via composed-pattern stories when those land"],
37
+ ["ProductPrice", "formatter component; covered by formatter unit tests"],
38
+ ["ProgressiveBlur", "decorative blur overlay; no a11y-relevant state surface"],
39
+ ["scroll-fade", "decorative scroll mask; no a11y-relevant state surface"],
40
+ ["ColorSwatch", "TODO — story coverage gap; ADR-0064 §2 cleanup PR queues this"],
41
+ ["ThemeToggle", "TODO — story coverage gap; ADR-0064 §2 cleanup PR queues this"],
42
+ ]);
43
+
44
+ function toPascal(slug: string): string {
45
+ return slug
46
+ .split(/[-_]/g)
47
+ .filter((s) => s.length > 0)
48
+ .map((s) => s[0].toUpperCase() + s.slice(1))
49
+ .join("");
50
+ }
51
+
52
+ describe("ADR-0064 §3 Layer 3 — primitive story coverage", () => {
53
+ it("every primitive has a matching .stories.tsx (or is allowlisted)", async () => {
54
+ const primitives = (await fs.readdir(PRIMITIVES_DIR))
55
+ .filter((f) => f.endsWith(".tsx"))
56
+ .map((f) => f.replace(/\.tsx$/, ""));
57
+
58
+ const stories = new Set(
59
+ (await fs.readdir(STORIES_DIR))
60
+ .filter((f) => f.endsWith(".stories.tsx"))
61
+ .map((f) => f.replace(/\.stories\.tsx$/, "")),
62
+ );
63
+
64
+ const violations: string[] = [];
65
+ for (const p of primitives) {
66
+ if (PRIMITIVES_WITHOUT_STORIES.has(p)) continue;
67
+ const expected = toPascal(p);
68
+ if (!stories.has(expected)) {
69
+ violations.push(`${p}.tsx → expected stories/${expected}.stories.tsx`);
70
+ }
71
+ }
72
+ if (violations.length > 0) {
73
+ throw new Error(
74
+ `Primitives without stories (ADR-0064 §3 Layer 3):\n ${violations.join("\n ")}\n` +
75
+ `Either add the story or allowlist with reason in PRIMITIVES_WITHOUT_STORIES.`,
76
+ );
77
+ }
78
+ });
79
+
80
+ it("every allowlist entry corresponds to a real primitive file", async () => {
81
+ const primitives = new Set(
82
+ (await fs.readdir(PRIMITIVES_DIR))
83
+ .filter((f) => f.endsWith(".tsx") || f === "index")
84
+ .map((f) => f.replace(/\.tsx$/, "")),
85
+ );
86
+ primitives.add("index");
87
+ const stale: string[] = [];
88
+ for (const allowed of PRIMITIVES_WITHOUT_STORIES.keys()) {
89
+ if (!primitives.has(allowed)) {
90
+ stale.push(allowed);
91
+ }
92
+ }
93
+ expect(
94
+ stale,
95
+ `Stale allowlist entries (primitive removed but exception remains): ${stale.join(", ")}`,
96
+ ).toEqual([]);
97
+ });
98
+ });