Skip to content

Commit cd3660f

Browse files
authored
Merge pull request #38 from peelar/feat/improve-gradient-quality-v2
Improve gradient quality with 2-stop gradients
2 parents ad09251 + e0b77c5 commit cd3660f

6 files changed

Lines changed: 194 additions & 92 deletions

File tree

components/layouts/shared/background-style.ts

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,105 @@
1-
import { LayoutConfig } from "@/domain/layout/types";
1+
import { LayoutConfig, CustomGradient } from "@/domain/layout/types";
22
import { Asset } from "@/domain/asset/types";
3-
import { customGradientToCss } from "@/domain/layout/gradients";
3+
import { customGradientToCss, isAdvancedGradient, isLegacyGradient } from "@/domain/layout/gradients";
44
import { getGradientById } from "@/domain/layout/gradient-presets";
55
import { tokenToCssColor } from "@/components/layouts/shared/color-utils";
66

7+
type LayoutGeometry =
8+
| { type: "radial"; direction: string }
9+
| { type: "linear"; angle: number };
10+
11+
/**
12+
* Get layout-specific gradient geometry based on layout type and variant.
13+
* This allows the same gradient colors to render differently per layout.
14+
*
15+
* Note: We use linear gradients for all layouts because the 3-stop gradient
16+
* structure (color → dark → color) doesn't work well with radial gradients
17+
* (creates a glow/ring effect instead of smooth coverage).
18+
*/
19+
function getLayoutGeometry(layoutId: string, variant?: string): LayoutGeometry {
20+
// Spotlight: diagonal toward screenshot side
21+
if (layoutId.startsWith("hero-center")) {
22+
// Left variant: text on left, screenshot on right → gradient flows left-to-right
23+
// Right variant: text on right, screenshot on left → gradient flows right-to-left
24+
return {
25+
type: "linear",
26+
angle: variant === "right" ? 270 : 90,
27+
};
28+
}
29+
30+
// Peak: linear perpendicular to screenshot edge
31+
if (layoutId.startsWith("popup-gradient")) {
32+
const angles: Record<string, number> = {
33+
left: 90,
34+
right: 270,
35+
center: 180,
36+
};
37+
return { type: "linear", angle: angles[variant ?? "center"] ?? 180 };
38+
}
39+
40+
// Backdrop: vertical gradient for centered screenshot
41+
if (layoutId === "adaptive-stage") {
42+
return { type: "linear", angle: 180 };
43+
}
44+
45+
// Code snippet: diagonal linear
46+
if (layoutId.startsWith("code-snippet")) {
47+
return { type: "linear", angle: 135 };
48+
}
49+
50+
// Default: diagonal linear
51+
return { type: "linear", angle: 135 };
52+
}
53+
54+
/**
55+
* Extract gradient stops as a CSS string from a CustomGradient
56+
*/
57+
function getGradientStopsString(gradient: CustomGradient): string {
58+
if (isAdvancedGradient(gradient)) {
59+
return gradient.stops
60+
.map((stop) => {
61+
if (stop.position !== undefined) {
62+
const position = stop.position <= 1 ? `${stop.position * 100}%` : `${stop.position}%`;
63+
return `${stop.color} ${position}`;
64+
}
65+
return stop.color;
66+
})
67+
.join(", ");
68+
}
69+
70+
if (isLegacyGradient(gradient)) {
71+
return `${gradient.from}, ${gradient.to}`;
72+
}
73+
74+
return "#6366f1, #8b5cf6";
75+
}
76+
77+
/**
78+
* Convert gradient to CSS with layout-specific geometry.
79+
* Colors come from the stored gradient; geometry is determined by layout type.
80+
*/
81+
function gradientToCssWithLayout(
82+
gradient: CustomGradient,
83+
layoutId: string,
84+
variant?: string
85+
): string {
86+
const stops = getGradientStopsString(gradient);
87+
const geometry = getLayoutGeometry(layoutId, variant);
88+
89+
if (geometry.type === "radial") {
90+
return `radial-gradient(${geometry.direction}, ${stops})`;
91+
}
92+
return `linear-gradient(${geometry.angle}deg, ${stops})`;
93+
}
94+
795
export function getBackgroundStyle(config: LayoutConfig, assetMap: Map<string, Asset>): string {
896
if (config.background?.type === "gradient") {
9-
if (config.background.customGradient) {
10-
return customGradientToCss(config.background.customGradient);
11-
}
97+
const gradient =
98+
config.background.customGradient ?? getGradientById(config.background.value)?.gradient;
1299

13-
const gradient = getGradientById(config.background.value);
14100
if (gradient) {
15-
return customGradientToCss(gradient.gradient);
101+
// Apply layout-specific geometry at render time
102+
return gradientToCssWithLayout(gradient, config.layoutId, config.variant);
16103
}
17104
} else if (config.background?.type === "image") {
18105
const bgAsset = assetMap.get(config.background.value);

domain/gradient-generation/color-extraction.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,8 @@ function classifyClusters(
336336
const saturationScore = avgSaturation * saturationWeight;
337337
const balanceScore = 1 - Math.abs(avgLightness - 0.5);
338338
const accentScore = saturationScore * 0.7 + balanceScore * 0.3;
339-
const isAccent = accentScore > 0.45 && avgSaturation > 0.35;
339+
// Stricter threshold to avoid picking muddy/neutral colors as accents
340+
const isAccent = accentScore > 0.5 && avgSaturation > 0.4;
340341

341342
return {
342343
hex,

domain/layout/gradient-presets.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,16 @@ export interface GradientPreset {
1010
}
1111

1212
/**
13-
* Create a 3-stop preset gradient using the same 3-color structure as screenshot-derived gradients.
13+
* Create a 2-stop preset gradient for smooth color transitions.
1414
*/
1515
function createPresetGradient(
16-
colors: { start: string; mid: string; end: string },
16+
colors: { start: string; end: string },
1717
angle = 90,
1818
): AdvancedGradient {
19-
const middle = colors.mid;
2019
return {
2120
type: "linear",
2221
stops: [
2322
{ color: colors.start, position: 0 },
24-
{ color: middle, position: 50 },
2523
{ color: colors.end, position: 100 },
2624
],
2725
angle,
@@ -35,7 +33,6 @@ export const GRADIENTS: GradientPreset[] = [
3533
name: "Hyper",
3634
gradient: createPresetGradient({
3735
start: "#ec4899",
38-
mid: "#d946ef",
3936
end: "#8b5cf6",
4037
}),
4138
textColor: "slate-50",
@@ -45,7 +42,6 @@ export const GRADIENTS: GradientPreset[] = [
4542
name: "Oceanic",
4643
gradient: createPresetGradient({
4744
start: "#2E3192",
48-
mid: "#1B73E8",
4945
end: "#1BFFFF",
5046
}),
5147
textColor: "slate-50",
@@ -55,7 +51,6 @@ export const GRADIENTS: GradientPreset[] = [
5551
name: "Cotton Candy",
5652
gradient: createPresetGradient({
5753
start: "#D4145A",
58-
mid: "#FF6B9D",
5954
end: "#FBB03B",
6055
}),
6156
textColor: "slate-900",
@@ -65,7 +60,6 @@ export const GRADIENTS: GradientPreset[] = [
6560
name: "Sunset",
6661
gradient: createPresetGradient({
6762
start: "#ff7e5f",
68-
mid: "#ff9a56",
6963
end: "#feb47b",
7064
}),
7165
textColor: "slate-900",
@@ -75,7 +69,6 @@ export const GRADIENTS: GradientPreset[] = [
7569
name: "Northern Lights",
7670
gradient: createPresetGradient({
7771
start: "#43cea2",
78-
mid: "#38a169",
7972
end: "#185a9d",
8073
}),
8174
textColor: "slate-50",
@@ -85,7 +78,6 @@ export const GRADIENTS: GradientPreset[] = [
8578
name: "Midnight",
8679
gradient: createPresetGradient({
8780
start: "#232526",
88-
mid: "#2d2f31",
8981
end: "#414345",
9082
}),
9183
textColor: "slate-50",
@@ -95,7 +87,6 @@ export const GRADIENTS: GradientPreset[] = [
9587
name: "Lush",
9688
gradient: createPresetGradient({
9789
start: "#56ab2f",
98-
mid: "#7bc043",
9990
end: "#a8e063",
10091
}),
10192
textColor: "slate-900",
@@ -105,7 +96,6 @@ export const GRADIENTS: GradientPreset[] = [
10596
name: "Frost",
10697
gradient: createPresetGradient({
10798
start: "#000428",
108-
mid: "#002856",
10999
end: "#004e92",
110100
}),
111101
textColor: "slate-50",

domain/layout/gradients/colors.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,47 @@ function oklchToHex(color: Oklch): string {
2525
}
2626
}
2727

28+
/**
29+
* Enforce minimum chromatic separation between two colors.
30+
* If colors are too similar in both hue and luminance, artificially
31+
* create contrast by pushing one color darker or lighter.
32+
*
33+
* This ensures gradients have visible direction rather than appearing muddy.
34+
*/
35+
export function enforceColorSeparation(
36+
colorA: string,
37+
colorB: string,
38+
minHueDelta = 30,
39+
minLightnessDelta = 0.25
40+
): { colorA: string; colorB: string } {
41+
const oklchA = hexToOklch(colorA);
42+
const oklchB = hexToOklch(colorB);
43+
44+
if (!oklchA || !oklchB) {
45+
return { colorA, colorB };
46+
}
47+
48+
// Calculate hue delta (handle wraparound at 360)
49+
const hueA = oklchA.h ?? 0;
50+
const hueB = oklchB.h ?? 0;
51+
const rawHueDelta = Math.abs(hueA - hueB);
52+
const hueDelta = Math.min(rawHueDelta, 360 - rawHueDelta);
53+
54+
const lightnessDelta = Math.abs(oklchA.l - oklchB.l);
55+
56+
// If both deltas are below threshold, invent contrast
57+
if (hueDelta < minHueDelta && lightnessDelta < minLightnessDelta) {
58+
// Push colorB darker if colorA is light, lighter if colorA is dark
59+
const shift = oklchA.l > 0.5 ? -0.35 : 0.35;
60+
return {
61+
colorA,
62+
colorB: enhanceColor(colorB, { lightnessShift: shift }),
63+
};
64+
}
65+
66+
return { colorA, colorB };
67+
}
68+
2869
/**
2970
* Enhanced color palette with categorized colors for gradient generation
3071
*/
@@ -126,6 +167,35 @@ function isDarkPalette(palette: ColorPalette): boolean {
126167
return avgLightness < 0.4; // Dark if average lightness < 0.4
127168
}
128169

170+
/**
171+
* Mix a color with a bias anchor color in OKLCH space.
172+
* Used to inject warm/cool tones into neutral palettes.
173+
*/
174+
function mixWithBias(hex: string, biasHex: string, ratio: number): string {
175+
const oklchBase = hexToOklch(hex);
176+
const oklchBias = hexToOklch(biasHex);
177+
178+
if (!oklchBase || !oklchBias) return hex;
179+
180+
// Mix lightness and chroma, but shift hue toward bias
181+
const l = oklchBase.l * (1 - ratio * 0.5) + oklchBias.l * (ratio * 0.5);
182+
const c = Math.max(oklchBase.c ?? 0, (oklchBias.c ?? 0) * ratio * 0.8);
183+
184+
// Blend hue toward bias color
185+
const baseH = oklchBase.h ?? 0;
186+
const biasH = oklchBias.h ?? 0;
187+
const h = baseH + (biasH - baseH) * ratio;
188+
189+
const mixed: Oklch = {
190+
mode: "oklch",
191+
l: Math.max(0.1, Math.min(0.9, l)),
192+
c: Math.min(0.35, c),
193+
h: ((h % 360) + 360) % 360,
194+
};
195+
196+
return oklchToHex(mixed);
197+
}
198+
129199
/**
130200
* Enhance and categorize color palette for gradient generation
131201
* Transforms raw screenshot colors into a structured palette for creating beautiful gradients
@@ -169,6 +239,16 @@ export function enhanceColorPalette(palette: ColorPalette): EnhancedColorPalette
169239
hero = enhanceColor(hero, { saturationBoost: 0.5 });
170240
}
171241

242+
// If truly neutral (very low saturation), inject warm/cool bias
243+
// This ensures neutral screenshots don't produce neutral (muddy) gradients
244+
if (isNeutral && saturation < 0.08) {
245+
const warmAnchor = "#f97316"; // orange
246+
const coolAnchor = "#6366f1"; // indigo
247+
// Pick based on average lightness - dark palettes get warm, light get cool
248+
const biasColor = isDark ? warmAnchor : coolAnchor;
249+
hero = mixWithBias(hero ?? base, biasColor, 0.4);
250+
}
251+
172252
if (isDark) {
173253
// Lighten the base for dark palettes to improve contrast
174254
base = enhanceColor(base, { lightnessShift: 0.3 });

0 commit comments

Comments
 (0)