Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 94 additions & 7 deletions components/layouts/shared/background-style.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,105 @@
import { LayoutConfig } from "@/domain/layout/types";
import { LayoutConfig, CustomGradient } from "@/domain/layout/types";
import { Asset } from "@/domain/asset/types";
import { customGradientToCss } from "@/domain/layout/gradients";
import { customGradientToCss, isAdvancedGradient, isLegacyGradient } from "@/domain/layout/gradients";
import { getGradientById } from "@/domain/layout/gradient-presets";
import { tokenToCssColor } from "@/components/layouts/shared/color-utils";

type LayoutGeometry =
| { type: "radial"; direction: string }
| { type: "linear"; angle: number };

/**
* Get layout-specific gradient geometry based on layout type and variant.
* This allows the same gradient colors to render differently per layout.
*
* Note: We use linear gradients for all layouts because the 3-stop gradient
* structure (color → dark → color) doesn't work well with radial gradients
* (creates a glow/ring effect instead of smooth coverage).
*/
function getLayoutGeometry(layoutId: string, variant?: string): LayoutGeometry {
// Spotlight: diagonal toward screenshot side
if (layoutId.startsWith("hero-center")) {
// Left variant: text on left, screenshot on right → gradient flows left-to-right
// Right variant: text on right, screenshot on left → gradient flows right-to-left
return {
type: "linear",
angle: variant === "right" ? 270 : 90,
};
}

// Peak: linear perpendicular to screenshot edge
if (layoutId.startsWith("popup-gradient")) {
const angles: Record<string, number> = {
left: 90,
right: 270,
center: 180,
};
return { type: "linear", angle: angles[variant ?? "center"] ?? 180 };
}

// Backdrop: vertical gradient for centered screenshot
if (layoutId === "adaptive-stage") {
return { type: "linear", angle: 180 };
}

// Code snippet: diagonal linear
if (layoutId.startsWith("code-snippet")) {
return { type: "linear", angle: 135 };
}

// Default: diagonal linear
return { type: "linear", angle: 135 };
}

/**
* Extract gradient stops as a CSS string from a CustomGradient
*/
function getGradientStopsString(gradient: CustomGradient): string {
if (isAdvancedGradient(gradient)) {
return gradient.stops
.map((stop) => {
if (stop.position !== undefined) {
const position = stop.position <= 1 ? `${stop.position * 100}%` : `${stop.position}%`;
return `${stop.color} ${position}`;
}
return stop.color;
})
.join(", ");
}

if (isLegacyGradient(gradient)) {
return `${gradient.from}, ${gradient.to}`;
}

return "#6366f1, #8b5cf6";
}

/**
* Convert gradient to CSS with layout-specific geometry.
* Colors come from the stored gradient; geometry is determined by layout type.
*/
function gradientToCssWithLayout(
gradient: CustomGradient,
layoutId: string,
variant?: string
): string {
const stops = getGradientStopsString(gradient);
const geometry = getLayoutGeometry(layoutId, variant);

if (geometry.type === "radial") {
return `radial-gradient(${geometry.direction}, ${stops})`;
}
return `linear-gradient(${geometry.angle}deg, ${stops})`;
Comment on lines +81 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Respect stored gradient geometry when rendering

Rendering now ignores the geometry stored on the CustomGradient. gradientToCssWithLayout derives the angle/type solely from getLayoutGeometry, so any type, angle, or direction saved on the gradient (set by the gradient picker’s angle control or by applyPreferredAngle) is discarded. This means rotating a gradient or choosing a non-linear type in the UI no longer changes the background—layout defaults always win.

Useful? React with 👍 / 👎.

}

export function getBackgroundStyle(config: LayoutConfig, assetMap: Map<string, Asset>): string {
if (config.background?.type === "gradient") {
if (config.background.customGradient) {
return customGradientToCss(config.background.customGradient);
}
const gradient =
config.background.customGradient ?? getGradientById(config.background.value)?.gradient;

const gradient = getGradientById(config.background.value);
if (gradient) {
return customGradientToCss(gradient.gradient);
// Apply layout-specific geometry at render time
return gradientToCssWithLayout(gradient, config.layoutId, config.variant);
}
} else if (config.background?.type === "image") {
const bgAsset = assetMap.get(config.background.value);
Expand Down
3 changes: 2 additions & 1 deletion domain/gradient-generation/color-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,8 @@ function classifyClusters(
const saturationScore = avgSaturation * saturationWeight;
const balanceScore = 1 - Math.abs(avgLightness - 0.5);
const accentScore = saturationScore * 0.7 + balanceScore * 0.3;
const isAccent = accentScore > 0.45 && avgSaturation > 0.35;
// Stricter threshold to avoid picking muddy/neutral colors as accents
const isAccent = accentScore > 0.5 && avgSaturation > 0.4;

return {
hex,
Expand Down
80 changes: 80 additions & 0 deletions domain/layout/gradients/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,47 @@ function oklchToHex(color: Oklch): string {
}
}

/**
* Enforce minimum chromatic separation between two colors.
* If colors are too similar in both hue and luminance, artificially
* create contrast by pushing one color darker or lighter.
*
* This ensures gradients have visible direction rather than appearing muddy.
*/
export function enforceColorSeparation(
colorA: string,
colorB: string,
minHueDelta = 30,
minLightnessDelta = 0.25
): { colorA: string; colorB: string } {
const oklchA = hexToOklch(colorA);
const oklchB = hexToOklch(colorB);

if (!oklchA || !oklchB) {
return { colorA, colorB };
}

// Calculate hue delta (handle wraparound at 360)
const hueA = oklchA.h ?? 0;
const hueB = oklchB.h ?? 0;
const rawHueDelta = Math.abs(hueA - hueB);
const hueDelta = Math.min(rawHueDelta, 360 - rawHueDelta);

const lightnessDelta = Math.abs(oklchA.l - oklchB.l);

// If both deltas are below threshold, invent contrast
if (hueDelta < minHueDelta && lightnessDelta < minLightnessDelta) {
// Push colorB darker if colorA is light, lighter if colorA is dark
const shift = oklchA.l > 0.5 ? -0.35 : 0.35;
return {
colorA,
colorB: enhanceColor(colorB, { lightnessShift: shift }),
};
}

return { colorA, colorB };
}

/**
* Enhanced color palette with categorized colors for gradient generation
*/
Expand Down Expand Up @@ -126,6 +167,35 @@ function isDarkPalette(palette: ColorPalette): boolean {
return avgLightness < 0.4; // Dark if average lightness < 0.4
}

/**
* Mix a color with a bias anchor color in OKLCH space.
* Used to inject warm/cool tones into neutral palettes.
*/
function mixWithBias(hex: string, biasHex: string, ratio: number): string {
const oklchBase = hexToOklch(hex);
const oklchBias = hexToOklch(biasHex);

if (!oklchBase || !oklchBias) return hex;

// Mix lightness and chroma, but shift hue toward bias
const l = oklchBase.l * (1 - ratio * 0.5) + oklchBias.l * (ratio * 0.5);
const c = Math.max(oklchBase.c ?? 0, (oklchBias.c ?? 0) * ratio * 0.8);

// Blend hue toward bias color
const baseH = oklchBase.h ?? 0;
const biasH = oklchBias.h ?? 0;
const h = baseH + (biasH - baseH) * ratio;

const mixed: Oklch = {
mode: "oklch",
l: Math.max(0.1, Math.min(0.9, l)),
c: Math.min(0.35, c),
h: ((h % 360) + 360) % 360,
};

return oklchToHex(mixed);
}

/**
* Enhance and categorize color palette for gradient generation
* Transforms raw screenshot colors into a structured palette for creating beautiful gradients
Expand Down Expand Up @@ -169,6 +239,16 @@ export function enhanceColorPalette(palette: ColorPalette): EnhancedColorPalette
hero = enhanceColor(hero, { saturationBoost: 0.5 });
}

// If truly neutral (very low saturation), inject warm/cool bias
// This ensures neutral screenshots don't produce neutral (muddy) gradients
if (isNeutral && saturation < 0.08) {
const warmAnchor = "#f97316"; // orange
const coolAnchor = "#6366f1"; // indigo
// Pick based on average lightness - dark palettes get warm, light get cool
const biasColor = isDark ? warmAnchor : coolAnchor;
hero = mixWithBias(hero ?? base, biasColor, 0.4);
}

if (isDark) {
// Lighten the base for dark palettes to improve contrast
base = enhanceColor(base, { lightnessShift: 0.3 });
Expand Down
24 changes: 20 additions & 4 deletions domain/layout/gradients/generator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { AspectCategory } from "../aspect";
import { AdvancedGradient, GradientStop, GradientType } from "./types";
import { ColorPalette } from "@/domain/asset/types";
import { enhanceColorPalette, EnhancedColorPalette, enhanceColor } from "./colors";
import {
enhanceColorPalette,
EnhancedColorPalette,
enhanceColor,
enforceColorSeparation,
} from "./colors";

type GradientVariation = {
colorPair?: [string, string];
Expand Down Expand Up @@ -123,14 +128,20 @@ const BACKGROUND_GRADIENT_COLORS = [
];

/**
* Generate three color stops: two most prominent screenshot colors + an ambient background color
* Generate gradient stops with balanced color distribution.
* Uses two screenshot colors + an ambient background color.
* Enforces chromatic separation to prevent muddy gradients.
*/
function generateGradientStops(
palette: EnhancedColorPalette,
strategy: "hero-base" | "multi-color" | "complementary" | "analogous" | "triadic" = "multi-color",
variation?: GradientVariation,
): GradientStop[] {
const [primary, secondary] = variation?.colorPair ?? getProminentScreenshotColors(palette);
const [rawPrimary, rawSecondary] = variation?.colorPair ?? getProminentScreenshotColors(palette);

// Enforce chromatic separation to prevent muddy gradients
const { colorA: primary, colorB: secondary } = enforceColorSeparation(rawPrimary, rawSecondary);

const seed = variation?.backgroundSeed ?? `${primary}-${secondary}-${strategy}`;
const background = getBackgroundAccentColor(
seed,
Expand All @@ -153,9 +164,14 @@ function generateGradientStops(
strategy,
);

// Use 4 stops to create balanced color distribution:
// - 0-35%: transition from start to background
// - 35-65%: hold background color (gives it ~30% visual presence)
// - 65-100%: transition from background to end
return [
{ color: start, position: 0 },
{ color: background, position: 50 },
{ color: background, position: 35 },
{ color: background, position: 65 },
{ color: end, position: 100 },
];
}
Expand Down
2 changes: 1 addition & 1 deletion domain/layout/gradients/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export type GradientColorSpace = "oklch" | "srgb" | "lab";
*/
export type AdvancedGradient = {
type: GradientType;
stops: GradientStop[]; // Always three stops (start, background, end)
stops: GradientStop[]; // Color stops with positions
direction?: string; // e.g., "to right", "45deg", "circle at center"
colorSpace?: GradientColorSpace; // defaults to "oklch" for perceptual uniformity
angle?: number; // for linear gradients in degrees (0-360)
Expand Down