@snowcone-app/ui 0.1.43 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. package/dist/styles.css +0 -1
@@ -0,0 +1,159 @@
1
+ "use client";
2
+
3
+ /**
4
+ * usePersonalizationShimmer — tracks the full personalization → mockup pipeline
5
+ * and returns whether a shimmer overlay should be visible.
6
+ *
7
+ * Shimmer is ON from the first personalization input until the final server-rendered
8
+ * mockup image has loaded in the browser. Handles throttled blob sends, stale server
9
+ * results, and rapid typing correctly.
10
+ *
11
+ * Must be used inside both `<PersonalizationProvider>` and `<RealtimeProvider>`.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * const heroRef = useRef<HTMLDivElement>(null);
16
+ * const { shimmerActive } = usePersonalizationShimmer(heroRef);
17
+ *
18
+ * return (
19
+ * <div ref={heroRef}>
20
+ * <MobileProductCarousel ... />
21
+ * {shimmerActive && <ShimmerOverlay />}
22
+ * </div>
23
+ * );
24
+ * ```
25
+ */
26
+
27
+ import { useState, useEffect, useRef, useCallback, type RefObject } from "react";
28
+ import { usePersonalizationContext } from "./PersonalizationContext";
29
+ import { useRealtimeOptional } from "../patterns/RealtimeProvider";
30
+
31
+ export interface UsePersonalizationShimmerReturn {
32
+ /** Whether the shimmer overlay should be visible. */
33
+ shimmerActive: boolean;
34
+ /**
35
+ * Manually trigger shimmer (e.g. when product options change).
36
+ * Shimmer clears automatically when a new image loads in the container.
37
+ */
38
+ triggerShimmer: () => void;
39
+ }
40
+
41
+ /**
42
+ * Track personalization → mockup shimmer state.
43
+ *
44
+ * @param containerRef - ref to the element containing mockup `<img>` tags.
45
+ * The hook watches for `src` attribute changes and `load` events inside this container.
46
+ * @param safetyTimeoutMs - auto-clear shimmer after this duration (default 10000ms).
47
+ */
48
+ export function usePersonalizationShimmer(
49
+ containerRef: RefObject<HTMLElement | null>,
50
+ safetyTimeoutMs = 10000,
51
+ ): UsePersonalizationShimmerReturn {
52
+ const personCtx = usePersonalizationContext();
53
+ const realtime = useRealtimeOptional();
54
+
55
+ const [shimmerActive, setShimmerActive] = useState(false);
56
+ const sdkSettledRef = useRef(false); // gate for personalization pipeline
57
+ const manualTriggerRef = useRef(false); // gate for manual triggers (option changes)
58
+ const safetyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
59
+
60
+ // Helper: start shimmer with safety timeout
61
+ const startShimmer = useCallback(() => {
62
+ setShimmerActive(true);
63
+ if (safetyTimeoutRef.current) clearTimeout(safetyTimeoutRef.current);
64
+ safetyTimeoutRef.current = setTimeout(
65
+ () => {
66
+ sdkSettledRef.current = false;
67
+ manualTriggerRef.current = false;
68
+ setShimmerActive(false);
69
+ },
70
+ safetyTimeoutMs,
71
+ );
72
+ }, [safetyTimeoutMs]);
73
+
74
+ // personValues change → shimmer ON, close gate, reset pipeline
75
+ useEffect(() => {
76
+ if (!personCtx?.isActive) return;
77
+
78
+ sdkSettledRef.current = false;
79
+ manualTriggerRef.current = false;
80
+ realtime?.resetPipelineSettled();
81
+ startShimmer();
82
+ }, [personCtx?.personValues, personCtx?.isActive, startShimmer]);
83
+
84
+ // Manual trigger (e.g. option changes) → shimmer ON, clear on next img load
85
+ const triggerShimmer = useCallback(() => {
86
+ sdkSettledRef.current = false;
87
+ manualTriggerRef.current = true;
88
+ startShimmer();
89
+ }, [startShimmer]);
90
+
91
+ // subscribePipelineSettled → open gate for IMG loads
92
+ useEffect(() => {
93
+ if (!realtime?.subscribePipelineSettled) return;
94
+ return realtime.subscribePipelineSettled(() => {
95
+ sdkSettledRef.current = true;
96
+ });
97
+ }, [realtime]);
98
+
99
+ // IMG load inside container while gate is open → shimmer OFF
100
+ useEffect(() => {
101
+ const container = containerRef.current;
102
+ if (!container) return;
103
+
104
+ const observer = new MutationObserver((mutations) => {
105
+ for (const m of mutations) {
106
+ if (m.type !== "attributes" || m.attributeName !== "src") continue;
107
+ const img = m.target as HTMLImageElement;
108
+ if (!container.contains(img)) continue;
109
+
110
+ // Gate: only track src changes when we're expecting a new image.
111
+ // sdkSettledRef = personalization pipeline done (waiting for img load)
112
+ // manualTriggerRef = option change (waiting for next img load)
113
+ const shouldTrack = sdkSettledRef.current || manualTriggerRef.current;
114
+ if (!shouldTrack) continue;
115
+
116
+ img.addEventListener(
117
+ "load",
118
+ () => {
119
+ // Re-check gates — user may have typed again (closing the gate)
120
+ if (sdkSettledRef.current || manualTriggerRef.current) {
121
+ sdkSettledRef.current = false;
122
+ manualTriggerRef.current = false;
123
+ setShimmerActive(false);
124
+ if (safetyTimeoutRef.current)
125
+ clearTimeout(safetyTimeoutRef.current);
126
+ }
127
+ },
128
+ { once: true },
129
+ );
130
+ img.addEventListener(
131
+ "error",
132
+ () => {
133
+ if (sdkSettledRef.current || manualTriggerRef.current) {
134
+ sdkSettledRef.current = false;
135
+ manualTriggerRef.current = false;
136
+ setShimmerActive(false);
137
+ }
138
+ },
139
+ { once: true },
140
+ );
141
+ }
142
+ });
143
+
144
+ observer.observe(container, {
145
+ attributes: true,
146
+ subtree: true,
147
+ attributeFilter: ["src"],
148
+ });
149
+ return () => observer.disconnect();
150
+ }, [containerRef]);
151
+
152
+ useEffect(() => {
153
+ return () => {
154
+ if (safetyTimeoutRef.current) clearTimeout(safetyTimeoutRef.current);
155
+ };
156
+ }, []);
157
+
158
+ return { shimmerActive, triggerShimmer };
159
+ }
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Personalization Utilities
5
+ */
6
+
7
+ import { useEffect, useState } from "react";
8
+
9
+ /** Normalize any hex color to uppercase 6-digit form (#RGB → #RRGGBB) */
10
+ export function normalizeHex(color: string): string {
11
+ let hex = color.trim().toUpperCase();
12
+ if (hex.length === 4 && hex.startsWith("#")) {
13
+ hex = "#" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
14
+ }
15
+ return hex;
16
+ }
17
+
18
+ /** Check if a string looks like a complete, loadable URL */
19
+ export function isValidImageUrl(url: string): boolean {
20
+ if (!url || url.length < 10) return false;
21
+ try {
22
+ const parsed = new URL(url);
23
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
24
+ if (!parsed.hostname.includes(".")) return false;
25
+ if (parsed.pathname.length <= 1) return false;
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** Debounce a string value — only emits after `delay` ms of inactivity */
33
+ export function useDebouncedValue(value: string, delay: number): string {
34
+ const [debounced, setDebounced] = useState(value);
35
+ useEffect(() => {
36
+ const timer = setTimeout(() => setDebounced(value), delay);
37
+ return () => clearTimeout(timer);
38
+ }, [value, delay]);
39
+ return debounced;
40
+ }
41
+
42
+ /**
43
+ * Scroll an input to sit just above the iOS keyboard after it appears.
44
+ * Uses visualViewport.height to determine the visible area above the keyboard.
45
+ */
46
+ export function scrollInputAboveKeyboard(input: HTMLElement) {
47
+ const position = () => {
48
+ const rect = input.getBoundingClientRect();
49
+ const visibleBottom =
50
+ window.visualViewport?.height || window.innerHeight * 0.5;
51
+ const target = visibleBottom - rect.height - 20;
52
+ const delta = rect.top - target;
53
+ if (Math.abs(delta) > 10) {
54
+ window.scrollBy({ top: delta, behavior: "smooth" });
55
+ }
56
+ };
57
+ setTimeout(position, 400);
58
+ window.visualViewport?.addEventListener("resize", position, { once: true });
59
+ }
@@ -0,0 +1,65 @@
1
+ import React from "react";
2
+ import { getBrand, brandAssets, type SupportedLanguage } from "../lib/locale";
3
+
4
+ export interface BrandLogoProps {
5
+ /** Language — selects the correct wordmark. */
6
+ locale?: SupportedLanguage;
7
+ /** Icon height in pixels (default: 32). Wordmark scales proportionally. */
8
+ size?: number;
9
+ /** Gap between icon and wordmark in pixels (default: 8). */
10
+ gap?: number;
11
+ /** Hide the wordmark; show icon only. */
12
+ iconOnly?: boolean;
13
+ /** Additional CSS class names for the container. */
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * BrandLogo — Composes the universal Rainbow Snowcone icon with a
19
+ * locale-aware wordmark. See ADR-0035.
20
+ *
21
+ * The icon is identical across all locales (persistent visual anchor).
22
+ * The wordmark varies: "Snowcone" for Latin-script locales, "雪绘" for zh-CN.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <BrandLogo /> // Icon + "Snowcone" wordmark
27
+ * <BrandLogo locale="zh-CN" /> // Icon + "雪绘" wordmark
28
+ * <BrandLogo iconOnly /> // Icon only (e.g., mobile nav)
29
+ * <BrandLogo size={48} gap={12} /> // Larger logo
30
+ * ```
31
+ */
32
+ export function BrandLogo({
33
+ locale = "en",
34
+ size = 32,
35
+ gap = 8,
36
+ iconOnly = false,
37
+ className,
38
+ }: BrandLogoProps) {
39
+ const brand = getBrand(locale);
40
+
41
+ return (
42
+ <span
43
+ className={className}
44
+ style={{ display: "inline-flex", alignItems: "center", gap }}
45
+ translate="no"
46
+ >
47
+ <img
48
+ src={brandAssets.icon_svg}
49
+ alt=""
50
+ aria-hidden="true"
51
+ width={size}
52
+ height={size}
53
+ style={{ width: size, height: size }}
54
+ />
55
+ {!iconOnly && (
56
+ <img
57
+ src={brand.wordmark}
58
+ alt={brand.localized_name}
59
+ lang={brand.lang}
60
+ style={{ height: size * 0.75 }}
61
+ />
62
+ )}
63
+ </span>
64
+ );
65
+ }
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { getBrand, type SupportedLanguage } from "../lib/locale";
3
+
4
+ export interface BrandNameProps {
5
+ /** Language to render the brand name for. */
6
+ locale?: SupportedLanguage;
7
+ /**
8
+ * Which name field to use:
9
+ * - "localized" — the locale's registered trademark (default). "Snowcone" or "雪绘".
10
+ * - "display" — mixed-language form for cross-cultural contexts. "雪绘 (Snowcone)".
11
+ * - "primary" — always "Snowcone" regardless of locale.
12
+ */
13
+ variant?: "localized" | "display" | "primary";
14
+ /** Additional CSS class names. */
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * BrandName — Renders the Snowcone brand name with trademark protection.
20
+ *
21
+ * Applies `translate="no"` to prevent browser auto-translate from mangling
22
+ * the registered trademark, and sets the correct `lang` attribute for
23
+ * screen reader pronunciation. See ADR-0035.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <BrandName /> // "Snowcone"
28
+ * <BrandName locale="zh-CN" /> // "雪绘"
29
+ * <BrandName locale="zh-CN" variant="display" /> // "雪绘 (Snowcone)"
30
+ * ```
31
+ */
32
+ export function BrandName({
33
+ locale = "en",
34
+ variant = "localized",
35
+ className,
36
+ }: BrandNameProps) {
37
+ const brand = getBrand(locale);
38
+
39
+ const name =
40
+ variant === "display"
41
+ ? brand.display_name
42
+ : variant === "primary"
43
+ ? brand.primary_name
44
+ : brand.localized_name;
45
+
46
+ return (
47
+ <span translate="no" lang={brand.lang} className={className}>
48
+ {name}
49
+ </span>
50
+ );
51
+ }
@@ -0,0 +1,123 @@
1
+ import * as React from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { cn } from '../lib/utils';
4
+ import { useSurface } from './surface';
5
+
6
+ const buttonVariants = cva(
7
+ 'inline-flex items-center justify-center gap-2 rounded-button text-sm transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 font-button',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ // Use proper Tailwind tokens — the previous `btn-primary-auto`
12
+ // class lives in ui-react/styles/globals.css, which snowcone (and
13
+ // any consumer using only defaults.css/base.css/utilities.css)
14
+ // does NOT import, so primary buttons rendered with no background
15
+ // there. `bg-primary text-primary-foreground` is universal.
16
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
17
+ primary: 'bg-primary text-primary-foreground hover:bg-primary/90', // HeroUI alias
18
+ secondary: 'bg-default text-default-foreground hover:bg-default/80',
19
+ tertiary: 'hover:bg-muted', // HeroUI alias
20
+ field: 'bg-field text-foreground hover:bg-field/80 shadow-soft dark:shadow-none rounded-input',
21
+ ghost: 'text-foreground/70 hover:text-foreground hover:bg-muted',
22
+ destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
23
+ outline: 'border border-divider bg-transparent hover:bg-muted',
24
+ link: 'text-primary underline-offset-4 hover:underline',
25
+ // Option variants for product selectors (sizes, colors)
26
+ 'option-text': 'px-5 py-2.5 text-base font-medium rounded-full border-2 bg-background min-w-[70px] data-[selected=true]:border-primary data-[selected=false]:border-border hover:border-primary/50',
27
+ 'option-swatch': 'relative h-12 w-12 shrink-0 aspect-square rounded-full data-[selected=true]:scale-105 data-[selected=true]:border-2 data-[selected=true]:border-primary ring-1 ring-border/30',
28
+ // Standalone action buttons (not part of a selection set)
29
+ 'action': 'px-5 py-2.5 text-base font-medium rounded-full bg-card shadow-sm hover:shadow-md transition-shadow',
30
+ 'action-icon': 'shrink-0 rounded-full bg-card shadow-sm hover:shadow-md transition-shadow px-3 py-2.5',
31
+ },
32
+ size: {
33
+ none: '',
34
+ default: 'h-10 px-4 py-2',
35
+ sm: 'h-9 px-3',
36
+ md: 'h-10 px-4 py-2', // HeroUI alias
37
+ lg: 'h-11 px-8',
38
+ icon: 'h-10 w-10',
39
+ // Toolbar size: 44px on mobile, 36px on desktop (md+)
40
+ toolbar: 'size-11 md:size-9 p-2 rounded-lg',
41
+ },
42
+ },
43
+ defaultVariants: {
44
+ variant: 'default',
45
+ size: 'default',
46
+ },
47
+ }
48
+ );
49
+
50
+ export interface ButtonProps
51
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
52
+ VariantProps<typeof buttonVariants> {
53
+ asChild?: boolean;
54
+ // HeroUI compatibility props
55
+ onPress?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
56
+ isPending?: boolean;
57
+ // Option selection state (for option-text and option-swatch variants)
58
+ selected?: boolean;
59
+ }
60
+
61
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
62
+ ({ className, variant, size, onPress, isPending, onClick, disabled, selected, children, ...props }, ref) => {
63
+ const { variant: surfaceVariant } = useSurface();
64
+ const isOnDefaultSurface = surfaceVariant === 'default';
65
+ const isOnSecondarySurface = surfaceVariant === 'secondary';
66
+
67
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
68
+ if (onPress) onPress(e);
69
+ if (onClick) onClick(e);
70
+ };
71
+
72
+ // Surface-aware variant overrides
73
+ // - On default surface (white card): dark mode needs lighter bg, light mode base color is fine
74
+ // - On secondary surface: both modes need the raised color for contrast
75
+ const surfaceOverrides = {
76
+ secondary:
77
+ variant === 'secondary' &&
78
+ (isOnSecondarySurface
79
+ ? 'bg-[var(--color-default-on-surface)] hover:bg-[var(--color-default-on-surface)]/80'
80
+ : isOnDefaultSurface
81
+ ? 'dark:bg-[var(--color-default-on-surface)] dark:hover:bg-[var(--color-default-on-surface)]/80'
82
+ : false),
83
+ field:
84
+ variant === 'field' &&
85
+ (isOnSecondarySurface
86
+ ? 'bg-[var(--color-field-on-surface)] hover:bg-[var(--color-field-on-surface)]/80'
87
+ : isOnDefaultSurface
88
+ ? 'dark:bg-[var(--color-field-on-surface)] dark:hover:bg-[var(--color-field-on-surface)]/80'
89
+ : false),
90
+ ghost:
91
+ variant === 'ghost' &&
92
+ (isOnDefaultSurface || isOnSecondarySurface) &&
93
+ 'hover:bg-[var(--color-default-on-surface)]',
94
+ outline:
95
+ variant === 'outline' &&
96
+ (isOnDefaultSurface || isOnSecondarySurface) &&
97
+ 'hover:bg-[var(--color-default-on-surface)]',
98
+ };
99
+
100
+ return (
101
+ <button
102
+ className={cn(
103
+ buttonVariants({ variant, size }),
104
+ surfaceOverrides.secondary,
105
+ surfaceOverrides.field,
106
+ surfaceOverrides.ghost,
107
+ surfaceOverrides.outline,
108
+ className
109
+ )}
110
+ ref={ref}
111
+ onClick={handleClick}
112
+ disabled={disabled || isPending}
113
+ data-selected={selected}
114
+ {...props}
115
+ >
116
+ {children}
117
+ </button>
118
+ );
119
+ }
120
+ );
121
+ Button.displayName = 'Button';
122
+
123
+ export { Button, buttonVariants };
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+
5
+ export interface ColorSwatchChoice {
6
+ value: string;
7
+ label: string;
8
+ hex?: string;
9
+ imageUrl?: string;
10
+ selected?: boolean;
11
+ disabled?: boolean;
12
+ }
13
+
14
+ export interface ColorSwatchProps {
15
+ /** Array of color choices to display */
16
+ choices: ColorSwatchChoice[];
17
+ /** Callback when a color is selected */
18
+ onChange: (value: string) => void;
19
+ /** Optional ARIA label for the color group */
20
+ ariaLabel?: string;
21
+ /** Show tooltip on selection (default: true) */
22
+ showTooltip?: boolean;
23
+ /** Custom size in pixels (default: 40) */
24
+ size?: number;
25
+ /** Custom className for the container */
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * ColorSwatch - Interactive color selector with visual feedback
31
+ *
32
+ * A primitive component for displaying color choices as clickable swatches with
33
+ * support for hex colors, image thumbnails, selection states, and tooltips.
34
+ * Designed for maximum accessibility and visual clarity.
35
+ *
36
+ * Features:
37
+ * - Hex color or image thumbnail backgrounds
38
+ * - Visual selection indicators (checkmark, rings)
39
+ * - Disabled state with diagonal slash
40
+ * - Hover scaling for interactivity
41
+ * - Tooltip feedback on selection
42
+ * - Screen reader announcements
43
+ * - Accessible with ARIA roles and labels
44
+ * - Dark mode support
45
+ * - Customizable size
46
+ * - Colorblind-friendly selection indicators
47
+ *
48
+ * **Visual States:**
49
+ * - Default: Simple round swatch with subtle border
50
+ * - Selected: Double ring + checkmark icon + pattern overlay
51
+ * - Disabled: Diagonal slash overlay
52
+ * - Hover: Slight scale animation
53
+ *
54
+ * **Accessibility:**
55
+ * - Keyboard navigable
56
+ * - Screen reader friendly with status announcements
57
+ * - Visible selection indicators for colorblind users
58
+ * - Proper ARIA roles and labels
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * // Basic color swatches
63
+ * <ColorSwatch
64
+ * choices={[
65
+ * { value: "red", label: "Red", hex: "#ff0000", selected: true },
66
+ * { value: "blue", label: "Blue", hex: "#0000ff" },
67
+ * { value: "green", label: "Green", hex: "#00ff00", disabled: true }
68
+ * ]}
69
+ * onChange={(value) => console.log('Selected:', value)}
70
+ * ariaLabel="Choose product color"
71
+ * />
72
+ * ```
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * // With image thumbnails (patterns/textures)
77
+ * <ColorSwatch
78
+ * choices={[
79
+ * { value: "marble", label: "Marble", imageUrl: "/textures/marble.jpg" },
80
+ * { value: "wood", label: "Wood Grain", imageUrl: "/textures/wood.jpg" }
81
+ * ]}
82
+ * onChange={handleMaterialChange}
83
+ * size={50}
84
+ * />
85
+ * ```
86
+ *
87
+ * @example
88
+ * ```tsx
89
+ * // Custom styling and no tooltips
90
+ * <ColorSwatch
91
+ * choices={colorOptions}
92
+ * onChange={setColor}
93
+ * showTooltip={false}
94
+ * size={60}
95
+ * className="gap-4 justify-center"
96
+ * />
97
+ * ```
98
+ *
99
+ * @param choices - Array of color/pattern choices to display
100
+ * @param onChange - Callback when a swatch is clicked (receives choice value)
101
+ * @param ariaLabel - Accessible label for the color group
102
+ * @param showTooltip - Show selection tooltip (default: true)
103
+ * @param size - Swatch diameter in pixels (default: 40)
104
+ * @param className - Additional CSS classes for container
105
+ */
106
+ export function ColorSwatch({
107
+ choices,
108
+ onChange,
109
+ ariaLabel,
110
+ showTooltip = true,
111
+ size = 40,
112
+ className = "",
113
+ }: ColorSwatchProps) {
114
+ const [activeTooltip, setActiveTooltip] = useState<string | null>(null);
115
+
116
+ useEffect(() => {
117
+ if (activeTooltip && showTooltip) {
118
+ const timer = setTimeout(() => {
119
+ setActiveTooltip(null);
120
+ }, 2000);
121
+ return () => clearTimeout(timer);
122
+ }
123
+ }, [activeTooltip, showTooltip]);
124
+
125
+ const handleSwatchClick = (choice: ColorSwatchChoice) => {
126
+ onChange(choice.value);
127
+ if (showTooltip) {
128
+ setActiveTooltip(choice.value);
129
+ }
130
+ };
131
+
132
+ return (
133
+ <>
134
+ <div
135
+ className={`flex flex-wrap gap-2 ${className}`}
136
+ role="group"
137
+ aria-label={ariaLabel}
138
+ >
139
+ {choices.map((choice) => (
140
+ <div key={choice.value} className="relative">
141
+ <button
142
+ type="button"
143
+ onClick={() => handleSwatchClick(choice)}
144
+ className={`relative rounded-full transition-all duration-200 flex-shrink-0 ${
145
+ choice.disabled ? "cursor-not-allowed" : "cursor-pointer"
146
+ } ${
147
+ choice.selected ? "scale-105 border-2 border-primary" : "ring-1 ring-border"
148
+ }`}
149
+ style={{
150
+ width: `${size}px`,
151
+ height: `${size}px`,
152
+ }}
153
+ aria-label={`${choice.label} color${choice.selected ? ', selected' : ''}`}
154
+ aria-pressed={choice.selected}
155
+ disabled={choice.disabled}
156
+ title={choice.label}
157
+ >
158
+ <span
159
+ className={`absolute ${
160
+ choice.selected ? "inset-[3px]" : "inset-0"
161
+ } rounded-full block bg-cover bg-center transition-all duration-200`}
162
+ style={{
163
+ backgroundColor: choice.hex || "#ccc",
164
+ backgroundImage: choice.imageUrl
165
+ ? `url(${choice.imageUrl})`
166
+ : undefined,
167
+ }}
168
+ />
169
+ {choice.disabled && (
170
+ <>
171
+ <span
172
+ className="absolute inset-0 rounded-full pointer-events-none opacity-80"
173
+ style={{
174
+ background:
175
+ "linear-gradient(to bottom right, transparent calc(50% - 3px), var(--color-foreground) calc(50% - 3px), var(--color-foreground) calc(50% + 3px), transparent calc(50% + 3px))",
176
+ }}
177
+ />
178
+ <span
179
+ className="absolute inset-0 rounded-full pointer-events-none bg-background opacity-40"
180
+ />
181
+ </>
182
+ )}
183
+ </button>
184
+ </div>
185
+ ))}
186
+ </div>
187
+
188
+ {/* Screen reader announcement and visual tooltip for swatch selection */}
189
+ {activeTooltip && showTooltip && (
190
+ <>
191
+ <div
192
+ className="sr-only"
193
+ role="status"
194
+ aria-live="polite"
195
+ aria-atomic="true"
196
+ >
197
+ {ariaLabel}: {
198
+ choices.find((c) => c.value === activeTooltip)?.label
199
+ } selected
200
+ </div>
201
+ {/* Visual tooltip for sighted users */}
202
+ <div
203
+ className="fixed bg-primary text-primary-foreground text-sm px-3 py-2 rounded-tooltip shadow-xl pointer-events-none"
204
+ style={{
205
+ top: "20px",
206
+ left: "50%",
207
+ transform: "translateX(-50%)",
208
+ zIndex: 9999,
209
+ maxWidth: "90vw",
210
+ }}
211
+ aria-hidden="true"
212
+ >
213
+ {ariaLabel}: {
214
+ choices.find((c) => c.value === activeTooltip)?.label
215
+ } selected
216
+ </div>
217
+ </>
218
+ )}
219
+ </>
220
+ );
221
+ }