@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.
- package/CHANGELOG.md +26 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- 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
|
+
}
|