Skip to content

Commit 33fafde

Browse files
authored
Merge pull request #35 from peelar/feat/improve-gradient-quality
Improve gradient quality with contrast enforcement and layout-aware geometry
2 parents 420f070 + a284bb5 commit 33fafde

5 files changed

Lines changed: 197 additions & 13 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/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 });

domain/layout/gradients/generator.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { AspectCategory } from "../aspect";
22
import { AdvancedGradient, GradientStop, GradientType } from "./types";
33
import { ColorPalette } from "@/domain/asset/types";
4-
import { enhanceColorPalette, EnhancedColorPalette, enhanceColor } from "./colors";
4+
import {
5+
enhanceColorPalette,
6+
EnhancedColorPalette,
7+
enhanceColor,
8+
enforceColorSeparation,
9+
} from "./colors";
510

611
type GradientVariation = {
712
colorPair?: [string, string];
@@ -123,14 +128,20 @@ const BACKGROUND_GRADIENT_COLORS = [
123128
];
124129

125130
/**
126-
* Generate three color stops: two most prominent screenshot colors + an ambient background color
131+
* Generate gradient stops with balanced color distribution.
132+
* Uses two screenshot colors + an ambient background color.
133+
* Enforces chromatic separation to prevent muddy gradients.
127134
*/
128135
function generateGradientStops(
129136
palette: EnhancedColorPalette,
130137
strategy: "hero-base" | "multi-color" | "complementary" | "analogous" | "triadic" = "multi-color",
131138
variation?: GradientVariation,
132139
): GradientStop[] {
133-
const [primary, secondary] = variation?.colorPair ?? getProminentScreenshotColors(palette);
140+
const [rawPrimary, rawSecondary] = variation?.colorPair ?? getProminentScreenshotColors(palette);
141+
142+
// Enforce chromatic separation to prevent muddy gradients
143+
const { colorA: primary, colorB: secondary } = enforceColorSeparation(rawPrimary, rawSecondary);
144+
134145
const seed = variation?.backgroundSeed ?? `${primary}-${secondary}-${strategy}`;
135146
const background = getBackgroundAccentColor(
136147
seed,
@@ -153,9 +164,14 @@ function generateGradientStops(
153164
strategy,
154165
);
155166

167+
// Use 4 stops to create balanced color distribution:
168+
// - 0-35%: transition from start to background
169+
// - 35-65%: hold background color (gives it ~30% visual presence)
170+
// - 65-100%: transition from background to end
156171
return [
157172
{ color: start, position: 0 },
158-
{ color: background, position: 50 },
173+
{ color: background, position: 35 },
174+
{ color: background, position: 65 },
159175
{ color: end, position: 100 },
160176
];
161177
}

domain/layout/gradients/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export type GradientColorSpace = "oklch" | "srgb" | "lab";
3232
*/
3333
export type AdvancedGradient = {
3434
type: GradientType;
35-
stops: GradientStop[]; // Always three stops (start, background, end)
35+
stops: GradientStop[]; // Color stops with positions
3636
direction?: string; // e.g., "to right", "45deg", "circle at center"
3737
colorSpace?: GradientColorSpace; // defaults to "oklch" for perceptual uniformity
3838
angle?: number; // for linear gradients in degrees (0-360)

0 commit comments

Comments
 (0)