Skip to content

Commit 3d266e7

Browse files
committed
nicer
1 parent 54185ff commit 3d266e7

4 files changed

Lines changed: 155 additions & 16 deletions

File tree

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

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@ type SampleStats = {
1414
avgLightness: number;
1515
avgSaturation: number;
1616
dominantHueAngle: number;
17+
accentHueAngle: number;
18+
accentSaturation: number;
1719
};
1820

1921
const MAX_SAMPLE_SIZE = 64;
2022
const MIN_ALPHA = 16;
2123
const NEUTRAL_SATURATION = 0.12;
24+
const ACCENT_SATURATION = 0.18;
2225

2326
function clamp(value: number, min: number, max: number): number {
2427
return Math.min(Math.max(value, min), max);
@@ -59,6 +62,11 @@ function rgbToHsl(r: number, g: number, b: number): Hsl {
5962
return { h, s, l };
6063
}
6164

65+
function hueDistance(a: number, b: number): number {
66+
const diff = Math.abs(a - b) % 360;
67+
return diff > 180 ? 360 - diff : diff;
68+
}
69+
6270
function hueToBucket(hue: number): HueBucket {
6371
if (hue >= 345 || hue < 15) return "red";
6472
if (hue < 45) return "orange";
@@ -155,7 +163,7 @@ async function sampleImageColors(src: string): Promise<SampleStats | null> {
155163
sumSaturation += hsl.s;
156164
count += 1;
157165

158-
if (hsl.s > 0.18) {
166+
if (hsl.s > 0.12) {
159167
const bucket = Math.floor(hsl.h / 30) % bucketCount;
160168
const weight = hsl.s * (0.6 + 0.4 * (1 - Math.abs(hsl.l - 0.5) * 2));
161169
buckets[bucket].weight += weight;
@@ -215,6 +223,7 @@ async function sampleImageColors(src: string): Promise<SampleStats | null> {
215223

216224
const accent = vibrantColor ?? dominantColor;
217225
const dominantHsl = rgbToHsl(dominantColor.r, dominantColor.g, dominantColor.b);
226+
const accentHsl = rgbToHsl(accent.r, accent.g, accent.b);
218227

219228
const palette: ColorPalette = {
220229
dominant: rgbToHex(dominantColor),
@@ -231,6 +240,8 @@ async function sampleImageColors(src: string): Promise<SampleStats | null> {
231240
avgLightness,
232241
avgSaturation,
233242
dominantHueAngle: dominantHsl.h,
243+
accentHueAngle: accentHsl.h,
244+
accentSaturation: accentHsl.s,
234245
};
235246
} catch (error) {
236247
console.warn("Color sampling failed:", error);
@@ -262,12 +273,26 @@ export async function extractColorSignatureFromImage(src: string): Promise<{
262273
const dominantHue = dominantHsl.s < NEUTRAL_SATURATION
263274
? "neutral"
264275
: hueToBucket(dominantHsl.h);
276+
const accentHue = stats.accentSaturation > ACCENT_SATURATION
277+
? hueToBucket(stats.accentHueAngle)
278+
: undefined;
279+
280+
const accentDominant =
281+
stats.accentSaturation > ACCENT_SATURATION &&
282+
(dominantHue === "neutral" || hueDistance(stats.accentHueAngle, dominantHsl.h) > 35);
283+
284+
const primaryHue = accentDominant ? (accentHue ?? dominantHue) : dominantHue;
285+
const primaryHueAngle = accentDominant ? stats.accentHueAngle : dominantHsl.h;
286+
287+
const strengthSample = Math.max(stats.avgSaturation, stats.accentSaturation * 0.8);
265288

266289
const signature: ColorSignature = {
267-
dominantHue,
268-
dominantHueAngle: stats.dominantHueAngle,
290+
dominantHue: primaryHue,
291+
dominantHueAngle: primaryHueAngle,
292+
accentHue,
293+
accentHueAngle: stats.accentHueAngle,
269294
brightness: classifyBrightness(stats.avgLightness),
270-
accentStrength: classifyStrength(stats.avgSaturation),
295+
accentStrength: classifyStrength(strengthSample),
271296
dominantColor: stats.palette.dominant,
272297
accentColor: stats.palette.accent,
273298
};

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,16 @@ function buildMutedWashGradient(colors: PaletteColors, brightness: ColorSignatur
101101
const washAlpha = brightness === "dark" ? 0.45 : brightness === "light" ? 0.55 : 0.5;
102102
const layers: MeshLayer[] = [
103103
{ color: hexToRgba(colors.neutral, washAlpha + 0.05), position: { x: 20, y: 25 }, size: 90 },
104-
{ color: hexToRgba(colors.primary, washAlpha), position: { x: 78, y: 18 }, size: 78 },
105-
{ color: hexToRgba(colors.secondary, washAlpha - 0.08), position: { x: 62, y: 80 }, size: 92 },
104+
{ color: hexToRgba(colors.secondary, washAlpha), position: { x: 78, y: 18 }, size: 78 },
105+
{ color: hexToRgba(colors.primary, washAlpha - 0.08), position: { x: 62, y: 80 }, size: 92 },
106106
{ color: hexToRgba(colors.glow, washAlpha - 0.12), position: { x: 22, y: 72 }, size: 70 },
107107
];
108108

109109
return {
110110
type: "linear",
111111
stops: [
112112
{ color: colors.neutral, position: 0 },
113+
{ color: colors.secondary, position: 55 },
113114
{ color: colors.primary, position: 100 },
114115
],
115116
meshLayers: layers,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type {
2121
export { isLegacyGradient, isAdvancedGradient, isMeshGradient, isAuroraGradient } from "./types";
2222

2323
export type { HueBucket, Brightness, AccentStrength, ColorSignature, GradientPalette } from "./palette";
24-
export { buildGradientPalette } from "./palette";
24+
export { buildGradientPalette, createComplementSignature } from "./palette";
2525

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

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

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type AccentStrength = "vibrant" | "muted" | "grayscale";
1515
export type ColorSignature = {
1616
dominantHue: HueBucket;
1717
dominantHueAngle: number;
18+
accentHue?: HueBucket;
19+
accentHueAngle?: number;
1820
brightness: Brightness;
1921
accentStrength: AccentStrength;
2022
dominantColor: string;
@@ -124,6 +126,16 @@ const STEP_BY_BRIGHTNESS: Record<Brightness, StepMap> = {
124126
};
125127

126128
const WARM_BUCKETS = new Set<HueBucket>(["red", "orange", "yellow", "pink"]);
129+
const HUE_BUCKETS: HueBucket[] = [
130+
"red",
131+
"orange",
132+
"yellow",
133+
"green",
134+
"teal",
135+
"blue",
136+
"purple",
137+
"pink",
138+
];
127139

128140
export type GradientPalette = {
129141
id: string;
@@ -144,6 +156,40 @@ function hueDistance(a: number, b: number): number {
144156
return diff > 180 ? 360 - diff : diff;
145157
}
146158

159+
function normalizeHue(angle: number): number {
160+
if (!Number.isFinite(angle)) return 0;
161+
const normalized = angle % 360;
162+
return normalized < 0 ? normalized + 360 : normalized;
163+
}
164+
165+
function hueToBucket(angle: number): HueBucket {
166+
const hue = normalizeHue(angle);
167+
if (hue >= 345 || hue < 15) return "red";
168+
if (hue < 45) return "orange";
169+
if (hue < 75) return "yellow";
170+
if (hue < 165) return "green";
171+
if (hue < 195) return "teal";
172+
if (hue < 255) return "blue";
173+
if (hue < 285) return "purple";
174+
if (hue < 345) return "pink";
175+
return "neutral";
176+
}
177+
178+
function hashString(value: string): number {
179+
let hash = 0;
180+
for (let i = 0; i < value.length; i += 1) {
181+
hash = (hash * 31 + value.charCodeAt(i)) % 1_000_000;
182+
}
183+
return hash;
184+
}
185+
186+
function fallbackAccentBucket(signature: ColorSignature): HueBucket {
187+
const seed = `${signature.dominantColor}:${signature.brightness}:${signature.accentStrength}`;
188+
const hash = hashString(seed);
189+
const index = hash % HUE_BUCKETS.length;
190+
return HUE_BUCKETS[index] ?? "blue";
191+
}
192+
147193
function resolveVariant(signature: ColorSignature): HueVariant {
148194
const variants = HUE_VARIANTS[signature.dominantHue] ?? HUE_VARIANTS.neutral;
149195
if (!Number.isFinite(signature.dominantHueAngle)) {
@@ -166,40 +212,107 @@ function pickScale(scale: ScaleName, step: ScaleKey): string {
166212
return COLOR_SCALES[scale][step];
167213
}
168214

215+
function resolveSecondaryHue(signature: ColorSignature): { hue: HueBucket; angle: number } {
216+
const baseAngle = normalizeHue(signature.dominantHueAngle);
217+
const accentAngle = signature.accentHueAngle;
218+
if (
219+
signature.accentHue &&
220+
typeof accentAngle === "number" &&
221+
(signature.dominantHue === "neutral" || hueDistance(baseAngle, accentAngle) >= 35)
222+
) {
223+
return { hue: signature.accentHue, angle: accentAngle };
224+
}
225+
226+
if (signature.dominantHue === "neutral") {
227+
const fallbackHue = fallbackAccentBucket(signature);
228+
const anchor = HUE_VARIANTS[fallbackHue]?.[0]?.anchor ?? 0;
229+
return { hue: fallbackHue, angle: anchor };
230+
}
231+
232+
const complementAngle = normalizeHue(baseAngle + 180);
233+
return { hue: hueToBucket(complementAngle), angle: complementAngle };
234+
}
235+
236+
function resolveAccentVariant(signature: ColorSignature): HueVariant | null {
237+
if (signature.accentHue) {
238+
return resolveVariant({
239+
...signature,
240+
dominantHue: signature.accentHue,
241+
dominantHueAngle:
242+
signature.accentHueAngle ??
243+
HUE_VARIANTS[signature.accentHue]?.[0]?.anchor ??
244+
0,
245+
});
246+
}
247+
248+
const fallback = fallbackAccentBucket(signature);
249+
return resolveVariant({
250+
...signature,
251+
dominantHue: fallback,
252+
dominantHueAngle: HUE_VARIANTS[fallback]?.[0]?.anchor ?? 0,
253+
});
254+
}
255+
256+
export function createComplementSignature(signature: ColorSignature): ColorSignature {
257+
const baseAngle = signature.dominantHue !== "neutral" || !signature.accentHueAngle
258+
? signature.dominantHueAngle
259+
: signature.accentHueAngle;
260+
const complementAngle = normalizeHue(baseAngle + 180);
261+
const complementHue = hueToBucket(complementAngle);
262+
263+
return {
264+
...signature,
265+
dominantHue: complementHue,
266+
dominantHueAngle: complementAngle,
267+
accentHue: signature.dominantHue,
268+
accentHueAngle: signature.dominantHueAngle,
269+
accentStrength: signature.accentStrength === "vibrant" ? "muted" : signature.accentStrength,
270+
};
271+
}
272+
169273
export function buildGradientPalette(signature: ColorSignature): GradientPalette {
170274
const steps = STEP_BY_BRIGHTNESS[signature.brightness];
171275
const variant = resolveVariant(signature);
276+
const secondaryHue = resolveSecondaryHue(signature);
277+
const secondaryVariant = resolveVariant({
278+
...signature,
279+
dominantHue: secondaryHue.hue,
280+
dominantHueAngle: secondaryHue.angle,
281+
});
172282
const neutralScale: ScaleName = WARM_BUCKETS.has(signature.dominantHue) ? "stone" : "slate";
173283

174284
if (signature.accentStrength === "grayscale") {
285+
const accentVariant = resolveAccentVariant(signature) ?? secondaryVariant;
175286
return {
176287
id: `neutral-${signature.brightness}`,
177288
hue: "neutral",
178289
brightness: signature.brightness,
179290
strength: signature.accentStrength,
180291
colors: {
181292
primary: pickScale("slate", steps.primary),
182-
secondary: pickScale("zinc", steps.secondary),
183-
tertiary: pickScale("slate", steps.tertiary),
184-
glow: pickScale("slate", steps.glow),
293+
secondary: pickScale(accentVariant.primary, "base"),
294+
tertiary: pickScale(accentVariant.secondary, "soft"),
295+
glow: pickScale(accentVariant.accent, "base"),
185296
neutral: pickScale("slate", steps.neutral),
186297
},
187298
};
188299
}
189300

190-
const secondaryScale = signature.accentStrength === "muted" ? neutralScale : variant.secondary;
191-
const accentScale = signature.accentStrength === "muted" ? variant.primary : variant.accent;
301+
const accentIsMuted = signature.accentStrength !== "vibrant";
302+
const accentSecondaryStep = accentIsMuted ? "soft" : (signature.brightness === "dark" ? "rich" : steps.secondary);
303+
const accentTertiaryStep = accentIsMuted ? "soft" : (signature.brightness === "dark" ? "base" : steps.tertiary);
304+
const accentGlowStep = accentIsMuted ? "base" : (signature.brightness === "dark" ? "base" : steps.glow);
192305

193306
return {
194-
id: `${variant.primary}-${variant.secondary}-${signature.brightness}-${signature.accentStrength}`,
307+
id: `${variant.primary}-${secondaryVariant.primary}-${signature.brightness}-${signature.accentStrength}`,
195308
hue: signature.dominantHue,
196309
brightness: signature.brightness,
197310
strength: signature.accentStrength,
198311
colors: {
199312
primary: pickScale(variant.primary, steps.primary),
200-
secondary: pickScale(secondaryScale, steps.secondary),
201-
tertiary: pickScale(accentScale, steps.tertiary),
202-
glow: pickScale(accentScale, steps.glow),
313+
secondary: pickScale(secondaryVariant.primary, accentSecondaryStep),
314+
tertiary: pickScale(secondaryVariant.secondary, accentTertiaryStep),
315+
glow: pickScale(secondaryVariant.accent, accentGlowStep),
203316
neutral: pickScale(neutralScale, steps.neutral),
204317
},
205318
};

0 commit comments

Comments
 (0)