135 lines
5.0 KiB
TypeScript
135 lines
5.0 KiB
TypeScript
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
|
import { clampChroma, formatHex, modeOklch, modeRgb, useMode } from 'culori/fn';
|
|
import useUserSettings from './use-user-settings';
|
|
|
|
// Linear-style minimal/neutral palette: a TIGHT blue -> violet band in OKLCH
|
|
// (perceptually uniform), kept moderate-chroma so fills read as clean and gentle
|
|
// rather than candy-colored. We deliberately stay narrow and DON'T fan out to
|
|
// green/magenta — in this aesthetic series are separated by LIGHTNESS, not by
|
|
// spreading across the hue wheel. Every series (including the first) is generated
|
|
// from this one ramp, so the whole palette stays in a single cohesive color
|
|
// family — no special high-saturation brand color that clashes with the rest.
|
|
const COOL_HUE_START = 250; // blue
|
|
const COOL_HUE_END = 315; // violet (stops short of magenta/pink, stays neutral-cool)
|
|
const COOL_HUE_RANGE = COOL_HUE_END - COOL_HUE_START;
|
|
|
|
/**
|
|
* Vivid, distinct cool accents — for places that need a handful of "primary"
|
|
* colors, one per card/section (e.g. the summary trend cards), NOT a stacked
|
|
* multi-series palette. Every color is anchor-quality (bright + saturated) and
|
|
* spread evenly across the blue→violet band, so the set reads as several equally
|
|
* strong primaries rather than one bold + several washed-out fills.
|
|
*/
|
|
export function useCoolAccents() {
|
|
useMode(modeRgb);
|
|
useMode(modeOklch);
|
|
|
|
const { isDarkTheme } = useUserSettings();
|
|
|
|
return useMemoizedFn((count: number): string[] => {
|
|
if (count <= 0) return [];
|
|
|
|
const l = isDarkTheme ? 0.62 : 0.64;
|
|
const c = isDarkTheme ? 0.16 : 0.2;
|
|
|
|
const out: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const t = count <= 1 ? 0 : i / (count - 1);
|
|
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
|
|
out.push(
|
|
formatHex(clampChroma({ mode: 'oklch', l, c, h: hue }, 'oklch'))
|
|
);
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
export default function useCoolColors() {
|
|
useMode(modeRgb);
|
|
useMode(modeOklch);
|
|
|
|
const { isDarkTheme } = useUserSettings();
|
|
|
|
return useMemoizedFn((count: number): string[] => {
|
|
if (count <= 0) return [];
|
|
|
|
// Moderate chroma: clean and crisp, but gentle (not neon). Too low reads as
|
|
// muddy/dirty; too high reads as harsh. Dark mode a touch lower so fills
|
|
// stay calm against the dark canvas.
|
|
const baseChroma = isDarkTheme ? 0.085 : 0.12;
|
|
|
|
// First series is the "primary" anchor: SAME blue family (the bluest end of
|
|
// the ramp, nearest the brand hue) but clearly brighter and more saturated —
|
|
// a vivid, clean brand-blue that reads as the base color. The rest of the
|
|
// palette stays low-chroma, so the anchor pops as the primary while the
|
|
// family still feels cohesive.
|
|
const colors: string[] = [
|
|
formatHex(
|
|
clampChroma(
|
|
{
|
|
mode: 'oklch',
|
|
l: isDarkTheme ? 0.62 : 0.66,
|
|
c: isDarkTheme ? 0.16 : 0.2,
|
|
h: COOL_HUE_START
|
|
},
|
|
'oklch'
|
|
)
|
|
)
|
|
];
|
|
|
|
const rest = count - 1;
|
|
if (rest <= 0) return colors;
|
|
|
|
// Separation is driven by LIGHTNESS, not hue. The hue band is narrow, so as
|
|
// the count grows we add lightness TIERS — each tier reuses the same narrow
|
|
// hue ramp at a distinct lightness level, multiplying how many separable
|
|
// colors fit while keeping the whole palette in one cohesive family.
|
|
const tiers = rest <= 6 ? 1 : rest <= 12 ? 2 : 3;
|
|
const steps = Math.ceil(rest / tiers);
|
|
|
|
// Distinct lightness levels per tier count (index 0 → 2 tiers, 1 → 3 tiers).
|
|
// Lighter, airier levels for a fresh/crisp feel; kept in the upper-mid range
|
|
// so fills stay clean and legible.
|
|
const lightTiers = isDarkTheme
|
|
? [
|
|
[0.68, 0.5],
|
|
[0.72, 0.6, 0.48]
|
|
]
|
|
: [
|
|
[0.82, 0.64],
|
|
[0.84, 0.72, 0.6]
|
|
];
|
|
// Single-tier light/dark zig-zag for the common small-count case.
|
|
const [lightLo, lightHi] = isDarkTheme ? [0.5, 0.68] : [0.64, 0.82];
|
|
|
|
for (let i = 0; i < rest; i++) {
|
|
// Cycle the tier on every step so consecutive series always differ in
|
|
// lightness — exactly where stacked bars are hardest to tell apart.
|
|
const tier = i % tiers;
|
|
const step = Math.floor(i / tiers);
|
|
|
|
// Even hue ramp across the narrow band. Each tier is offset by a fraction
|
|
// of a step so same-step colors in different tiers don't share a hue.
|
|
const t = steps <= 1 ? 0.5 : (step + tier / tiers) / steps;
|
|
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
|
|
|
|
// 1 tier (≤6 series): light/dark zig-zag. 2-3 tiers: the tier's level.
|
|
const lightness =
|
|
tiers === 1 ? (i % 2 ? lightLo : lightHi) : lightTiers[tiers - 2][tier];
|
|
|
|
// Clamp chroma into the sRGB gamut so values don't get distorted by a raw
|
|
// channel clip when serialized to hex.
|
|
colors.push(
|
|
formatHex(
|
|
clampChroma(
|
|
{ mode: 'oklch', l: lightness, c: baseChroma, h: hue },
|
|
'oklch'
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
return colors;
|
|
});
|
|
}
|