Skip to content

Commit a59d007

Browse files
committed
nicest
1 parent 3d266e7 commit a59d007

11 files changed

Lines changed: 232 additions & 32 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"dopeshot-app": minor
3+
---
4+
5+
Introduce palette-matched gradients driven by screenshot color analysis
6+
7+
- Extract dominant/accent/muted hues from screenshots for gradient matching
8+
- Generate six gradient styles with multi-hue variation and alternate secondary palettes
9+
- Add gradient playground for previewing palettes
10+
- Make radial "beam" gradient directional for Peak layouts and shift it downward
11+
- Avoid showing fallback gradients while screenshot analysis is in progress

apps/app/src/app/(playground)/_components/playground-workspace.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { Monitor, Smartphone } from "lucide-react";
66
import { CoverPreview } from "@/components/cover-preview";
77
import { PreviewViewport } from "@/app/(playground)/_components/preview-viewport";
88
import { ScreenshotZoomSlider } from "@/components/selectors/screenshot-zoom-slider";
9-
import { screenshotZoomAtom, configAtom, orientationAtom, type Orientation } from "@/hooks/atoms";
9+
import {
10+
screenshotZoomAtom,
11+
configAtom,
12+
orientationAtom,
13+
gradientOptionsAtom,
14+
isAnalyzingColorsAtom,
15+
type Orientation,
16+
} from "@/hooks/atoms";
1017
import {
1118
LAYOUT_DEFINITIONS,
1219
getLayoutDefinition,
@@ -57,12 +64,15 @@ export function PlaygroundWorkspace({
5764
const [orientation, setOrientation] = useAtom(orientationAtom);
5865
const config = useAtomValue(configAtom);
5966
const setConfig = useSetAtom(configAtom);
67+
const gradientOptions = useAtomValue(gradientOptionsAtom);
68+
const isAnalyzingColors = useAtomValue(isAnalyzingColorsAtom);
6069
const [bottomWhitespace, setBottomWhitespace] = useState(0);
6170
const isMobile = useMobileDetection();
6271
const hasAutoSetOrientation = useRef(false);
6372

6473
const isBackdropLayout =
6574
config.layoutId === "adaptive-stage" || config.layoutId === "full-visual";
75+
const shouldHoldCanvas = hasScreenshot && (isAnalyzingColors || gradientOptions.length === 0);
6676

6777
// Set mobile as default orientation on mobile devices
6878
useEffect(() => {
@@ -236,11 +246,18 @@ export function PlaygroundWorkspace({
236246
surfaceHeight={canvasHeight}
237247
onViewportMetricsChange={handleViewportMetricsChange}
238248
>
239-
<CoverPreview
240-
showEmptyState={showEmptyState}
241-
showLoadingState={showLoadingState}
242-
onEmptyStateClick={onEmptyStateClick}
243-
/>
249+
<div
250+
className={cn(
251+
"transition-opacity duration-300",
252+
shouldHoldCanvas ? "opacity-0 pointer-events-none" : "opacity-100",
253+
)}
254+
>
255+
<CoverPreview
256+
showEmptyState={showEmptyState}
257+
showLoadingState={showLoadingState}
258+
onEmptyStateClick={onEmptyStateClick}
259+
/>
260+
</div>
244261
</PreviewViewport>
245262
{showFocusHint ? (
246263
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
@@ -251,7 +268,7 @@ export function PlaygroundWorkspace({
251268
) : null}
252269
</div>
253270

254-
{hasScreenshot ? (
271+
{hasScreenshot && !shouldHoldCanvas ? (
255272
<div className="relative z-10">
256273
<div
257274
style={

apps/app/src/components/layouts/shared/OrganicBlobsOverlay.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useMemo } from "react";
1+
import { memo, useMemo, useId } from "react";
22
import type { LayoutConfig } from "@/domain/layout/types";
33
import { isAdvancedGradient, isLegacyGradient } from "@/domain/layout/gradients";
44
import { tokenToCssColor } from "@/components/layouts/shared/color-utils";
@@ -39,6 +39,7 @@ interface OrganicBlobsOverlayProps {
3939
function OrganicBlobsOverlayComponent({ config }: OrganicBlobsOverlayProps) {
4040
const [primary, secondary] = useMemo(() => getTwoColorsFromConfig(config), [config]);
4141
const variantKey = config.background?.patternVariant ?? "v1";
42+
const maskId = useId();
4243

4344
// Determine layout variant to adjust blob positions
4445
// "left" variant = content left, screenshot right -> put blobs on left
@@ -95,15 +96,33 @@ function OrganicBlobsOverlayComponent({ config }: OrganicBlobsOverlayProps) {
9596
return `translate(${x} ${y}) rotate(45) scale(0.92) translate(-${x} -${y})`;
9697
}, [blobPositions.b]);
9798

99+
const shouldMaskTop = config.layoutId.startsWith("popup-gradient");
100+
const maskUrl = shouldMaskTop ? `url(#${maskId}-mask)` : undefined;
101+
98102
return (
99103
<svg
100104
className="pointer-events-none absolute inset-0 z-[1]"
101105
viewBox="0 0 100 100"
102106
preserveAspectRatio="none"
103107
aria-hidden="true"
104108
>
105-
<path d={blobA} fill={primary} opacity={0.1} />
106-
<path d={blobB} fill={secondary} opacity={0.2} transform={blobBTransform} />
109+
{shouldMaskTop ? (
110+
<defs>
111+
<linearGradient id={`${maskId}-fade`} x1="0" y1="0" x2="0" y2="1">
112+
<stop offset="0%" stopColor="black" />
113+
<stop offset="40%" stopColor="black" />
114+
<stop offset="48%" stopColor="white" />
115+
<stop offset="100%" stopColor="white" />
116+
</linearGradient>
117+
<mask id={`${maskId}-mask`}>
118+
<rect x="0" y="0" width="100" height="100" fill={`url(#${maskId}-fade)`} />
119+
</mask>
120+
</defs>
121+
) : null}
122+
<g mask={maskUrl}>
123+
<path d={blobA} fill={primary} opacity={0.1} />
124+
<path d={blobB} fill={secondary} opacity={0.2} transform={blobBTransform} />
125+
</g>
107126
</svg>
108127
);
109128
}

apps/app/src/components/layouts/shared/background-style.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ function gradientToCssWithLayout(
7979
layoutId: string,
8080
variant?: string
8181
): string {
82+
if (isAdvancedGradient(gradient) && gradient.type === "radial" && gradient.layoutHint === "beam") {
83+
const adjusted = {
84+
...gradient,
85+
direction: getBeamDirection(layoutId, variant),
86+
};
87+
return customGradientToCss(adjusted);
88+
}
89+
8290
// Complex gradients: use canonical renderer directly (no modifications)
8391
if (!isSimpleGradient(gradient)) {
8492
return customGradientToCss(gradient);
@@ -109,6 +117,21 @@ function gradientToCssWithLayout(
109117
return customGradientToCss(gradient);
110118
}
111119

120+
function getBeamDirection(layoutId: string, variant?: string): string {
121+
const y = 45; // Move beam 10% down from the default 35%
122+
123+
if (layoutId.startsWith("popup-gradient")) {
124+
const internalVariant = (variant as "left" | "right" | "center" | undefined) ?? "center";
125+
const screenshotSide =
126+
internalVariant === "left" ? "right" : internalVariant === "right" ? "left" : "center";
127+
128+
const x = screenshotSide === "left" ? 30 : screenshotSide === "right" ? 70 : 50;
129+
return `circle at ${x}% ${y}%`;
130+
}
131+
132+
return `circle at 50% ${y}%`;
133+
}
134+
112135
export function getBackgroundStyle(config: LayoutConfig, assetMap: Map<string, Asset>): string {
113136
if (config.background?.type === "gradient") {
114137
const gradient = config.background.customGradient;

apps/app/src/components/selectors/gradient-picker.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
} from "@/domain/layout/gradients";
1717
import { cn } from "@/lib/utils/cn";
1818
import { BackgroundSwatch } from "@/components/selectors/background-swatch";
19-
import { configAtom, gradientOptionsAtom } from "@/hooks/atoms";
19+
import { configAtom, gradientOptionsAtom, isAnalyzingColorsAtom } from "@/hooks/atoms";
20+
import { Skeleton } from "@/components/ui/skeleton";
2021
import { createOrganicBlobsPreviewDataUrl } from "@/domain/layout/patterns/organic-blobs";
2122

2223
interface GradientPickerProps {
@@ -30,10 +31,15 @@ export function GradientPicker({ onChangeAction, variant = "default" }: Gradient
3031
const background =
3132
config.background ?? ({ type: "gradient", value: "custom" } as BackgroundConfig);
3233
const hasScreenshot = Boolean(config.assets?.screenshot);
34+
const isAnalyzingColors = useAtomValue(isAnalyzingColorsAtom);
3335

3436
const generatedGradients = useAtomValue(gradientOptionsAtom);
3537
const fallbackGradients = useMemo((): CustomGradient[] => generateGradientOptions(), []);
36-
const availableGradients = generatedGradients.length > 0 ? generatedGradients : fallbackGradients;
38+
const availableGradients = hasScreenshot
39+
? generatedGradients
40+
: generatedGradients.length > 0
41+
? generatedGradients
42+
: fallbackGradients;
3743
const hasGradients = availableGradients.length > 0;
3844
const displayGradients = useMemo(() => {
3945
// Inline variant (mobile): show first 4 gradients (3 linear + mesh), exclude ambient
@@ -113,6 +119,20 @@ export function GradientPicker({ onChangeAction, variant = "default" }: Gradient
113119
[onChangeAction],
114120
);
115121

122+
if (hasScreenshot && (isAnalyzingColors || generatedGradients.length === 0)) {
123+
const gridClass = variant === "inline" ? "grid-cols-4" : "grid-cols-3";
124+
const items = variant === "inline" ? 4 : 6;
125+
return (
126+
<div className="space-y-3">
127+
<div className={cn("grid gap-3", gridClass)}>
128+
{Array.from({ length: items }).map((_, index) => (
129+
<Skeleton key={`gradient-skeleton-${index}`} className="h-12 w-full rounded-lg" />
130+
))}
131+
</div>
132+
</div>
133+
);
134+
}
135+
116136
if (variant === "inline") {
117137
return (
118138
<div className="space-y-3">

apps/app/src/domain/asset/color-analysis.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,37 @@ async function sampleImageColors(src: string): Promise<SampleStats | null> {
198198
}
199199
}
200200

201-
if (bestBucket.weight > 0) {
201+
const sortedBuckets = [...buckets]
202+
.map((bucket, index) => ({ ...bucket, index }))
203+
.sort((a, b) => b.weight - a.weight);
204+
205+
const primaryBucket = sortedBuckets[0];
206+
const secondaryBucket = sortedBuckets.find((bucket, idx) => {
207+
if (idx === 0) return false;
208+
if (bucket.weight <= 0 || !primaryBucket || primaryBucket.weight === 0) return false;
209+
210+
const primaryColor = {
211+
r: Math.round(primaryBucket.r / primaryBucket.weight),
212+
g: Math.round(primaryBucket.g / primaryBucket.weight),
213+
b: Math.round(primaryBucket.b / primaryBucket.weight),
214+
};
215+
const secondaryColor = {
216+
r: Math.round(bucket.r / bucket.weight),
217+
g: Math.round(bucket.g / bucket.weight),
218+
b: Math.round(bucket.b / bucket.weight),
219+
};
220+
const primaryHsl = rgbToHsl(primaryColor.r, primaryColor.g, primaryColor.b);
221+
const secondaryHsl = rgbToHsl(secondaryColor.r, secondaryColor.g, secondaryColor.b);
222+
const hueGap = hueDistance(primaryHsl.h, secondaryHsl.h);
223+
const ratio = bucket.weight / primaryBucket.weight;
224+
return hueGap >= 20 && ratio >= 0.12;
225+
});
226+
227+
if (primaryBucket && primaryBucket.weight > 0) {
202228
dominant = {
203-
r: Math.round(bestBucket.r / bestBucket.weight),
204-
g: Math.round(bestBucket.g / bestBucket.weight),
205-
b: Math.round(bestBucket.b / bestBucket.weight),
229+
r: Math.round(primaryBucket.r / primaryBucket.weight),
230+
g: Math.round(primaryBucket.g / primaryBucket.weight),
231+
b: Math.round(primaryBucket.b / primaryBucket.weight),
206232
};
207233
} else {
208234
dominant = {
@@ -221,7 +247,15 @@ async function sampleImageColors(src: string): Promise<SampleStats | null> {
221247
}
222248
: dominantColor;
223249

224-
const accent = vibrantColor ?? dominantColor;
250+
const secondaryColor = secondaryBucket && secondaryBucket.weight > 0
251+
? {
252+
r: Math.round(secondaryBucket.r / secondaryBucket.weight),
253+
g: Math.round(secondaryBucket.g / secondaryBucket.weight),
254+
b: Math.round(secondaryBucket.b / secondaryBucket.weight),
255+
}
256+
: null;
257+
258+
const accent = secondaryColor ?? vibrantColor ?? dominantColor;
225259
const dominantHsl = rgbToHsl(dominantColor.r, dominantColor.g, dominantColor.b);
226260
const accentHsl = rgbToHsl(accent.r, accent.g, accent.b);
227261

apps/app/src/domain/layout/gradients/generator.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import { AdvancedGradient, MeshLayer } from "./types";
1414
import { hexToRgba } from "./utils";
15-
import { buildGradientPalette, ColorSignature } from "./palette";
15+
import { buildGradientPalette, ColorSignature, getSecondaryHueCandidates } from "./palette";
1616

1717
const DEFAULT_SIGNATURE: ColorSignature = {
1818
dominantHue: "purple",
@@ -81,6 +81,7 @@ function buildRadialGlowGradient(colors: PaletteColors): AdvancedGradient {
8181
{ color: colors.primary, position: 45 },
8282
{ color: colors.secondary, position: 100 },
8383
],
84+
layoutHint: "beam",
8485
colorSpace: "oklch",
8586
};
8687
}
@@ -119,14 +120,17 @@ function buildMutedWashGradient(colors: PaletteColors, brightness: ColorSignatur
119120
}
120121

121122
export function generateGradientOptions(signature: ColorSignature = DEFAULT_SIGNATURE): AdvancedGradient[] {
122-
const palette = buildGradientPalette(signature);
123+
const [baseSecondary, altSecondaryA, altSecondaryB] = getSecondaryHueCandidates(signature);
124+
const palette = buildGradientPalette(signature, baseSecondary);
125+
const paletteAltA = altSecondaryA ? buildGradientPalette(signature, altSecondaryA) : palette;
126+
const paletteAltB = altSecondaryB ? buildGradientPalette(signature, altSecondaryB) : palette;
123127

124128
return [
125129
buildMeshGradient(palette.colors, signature.brightness),
126-
buildAuroraGradient(palette.colors),
130+
buildAuroraGradient(paletteAltA.colors),
127131
buildLinearBoldGradient(palette.colors),
128-
buildRadialGlowGradient(palette.colors),
132+
buildRadialGlowGradient(paletteAltB.colors),
129133
buildLinearSoftGradient(palette.colors),
130-
buildMutedWashGradient(palette.colors, signature.brightness),
134+
buildMutedWashGradient(paletteAltA.colors, signature.brightness),
131135
];
132136
}

apps/app/src/domain/layout/gradients/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,15 @@ export type {
2020

2121
export { isLegacyGradient, isAdvancedGradient, isMeshGradient, isAuroraGradient } from "./types";
2222

23-
export type { HueBucket, Brightness, AccentStrength, ColorSignature, GradientPalette } from "./palette";
24-
export { buildGradientPalette, createComplementSignature } from "./palette";
23+
export type {
24+
HueBucket,
25+
Brightness,
26+
AccentStrength,
27+
ColorSignature,
28+
GradientPalette,
29+
SecondaryHue,
30+
} from "./palette";
31+
export { buildGradientPalette, createComplementSignature, getSecondaryHueCandidates } from "./palette";
2532

2633
// Gradient generation (palette-matched)
2734
export { generateGradientOptions } from "./generator";

0 commit comments

Comments
 (0)