diff --git a/src/hooks/use-cool-colors.ts b/src/hooks/use-cool-colors.ts index e793a28c..3848f3d3 100644 --- a/src/hooks/use-cool-colors.ts +++ b/src/hooks/use-cool-colors.ts @@ -1,55 +1,132 @@ import useMemoizedFn from 'ahooks/lib/useMemoizedFn'; -import { converter, modeHsl, modeRgb, useMode } from 'culori/fn'; +import { clampChroma, formatHex, modeOklch, modeRgb, useMode } from 'culori/fn'; +import useUserSettings from './use-user-settings'; -const toHsl = converter('hsl'); - -const DEFAULT_BRAND_HUE = 211; // brand color (HSL hue) - -// Cool palette spans blue -> violet. The range starts just above the brand hue -// so generated colors never sit right next to the brand color. -const COOL_HUE_START = 215; -const COOL_HUE_END = 310; +// 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; -function colorStringToHue(input?: string): number | undefined { - if (!input) return undefined; - const color = toHsl(input); - if (!color || typeof color.h !== 'number') return undefined; - return color.h; -} - -export default function useCoolColors() { - // const brandHue = useMemo( - // () => colorStringToHue(userSettings.colorPrimary) ?? DEFAULT_BRAND_HUE, - // [userSettings.colorPrimary] - // ); +/** + * 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(modeHsl); + useMode(modeOklch); - const brandHue = DEFAULT_BRAND_HUE; + const { isDarkTheme } = useUserSettings(); return useMemoizedFn((count: number): string[] => { if (count <= 0) return []; - // First series keeps the brand color. - const colors: string[] = [`hsl(${brandHue}, 100%, 50%)`]; + 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++) { - // Evenly divide the hue range. Because `count` is known up front, even - // spacing guarantees the largest possible minimum gap between any two - // hues — golden-ratio sampling can land two values close together after - // wrapping into this narrow cool band. - const t = rest <= 1 ? 0.5 : (i + 0.5) / rest; + // 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; - // Zig-zag saturation/lightness on a second axis. HSL is not perceptually - // uniform (blues/purples look alike at equal hue steps), so alternating - // lightness is what keeps adjacent series clearly distinct. - const saturation = 72 + (i % 3) * 9; // 72 / 81 / 90 - const lightness = i % 2 ? 46 : 62; // alternate darker / lighter + // 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]; - colors.push(`hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`); + // 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; diff --git a/src/pages/usage/summary-tab/index.tsx b/src/pages/usage/summary-tab/index.tsx index 273bf227..e4826812 100644 --- a/src/pages/usage/summary-tab/index.tsx +++ b/src/pages/usage/summary-tab/index.tsx @@ -21,7 +21,7 @@ * each domain's natural unit (tokens / GPU-Hours / GB-Days); a true * cross-resource split needs a common unit. */ -import useCoolColors from '@/hooks/use-cool-colors'; +import { useCoolAccents } from '@/hooks/use-cool-colors'; import BarChart from '@/pages/_components/bar-chart'; import PieChart from '@/pages/_components/pie-chart'; import { formatLargeNumber } from '@/utils'; @@ -209,7 +209,8 @@ const SummaryTab: React.FC = () => { const access = useAccess(); const intl = useIntl(); const t = (id: string) => intl.formatMessage({ id }); - const coolColors = useCoolColors()(8); + // One vivid primary per summary card (Tokens / Compute / Storage). + const coolColors = useCoolAccents()(3); // No All/My dropdown (matches the Tokens tab): managers see the org-wide // view and narrow it with the user filter, others only their own rows. @@ -499,13 +500,13 @@ const SummaryTab: React.FC = () => {