Replace the plain gray ramp with a material-tier color system (bronze/silver/gold/platinum/onyx)
Package cards now progress through named material tiers instead of a single gray/black hue - each tier gets its own accent color for border, glow and price text (icon chip too), while the CTA button stays brand green everywhere so the primary action stays recognizable across tiers. Found and fixed a real bug along the way: blending adjacent materials directly in HSL swept straight through unrelated hues (e.g. bronze at 24° to silver at 212° passed through yellow-green), producing a muddy khaki look instead of a clean bronze-to-silver transition. Blending in RGB space instead fixes this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa5f7510e4
commit
2af4af0607
1 changed files with 83 additions and 66 deletions
|
|
@ -9,37 +9,30 @@ import { useMaintenance } from '../../context/MaintenanceContext.jsx';
|
||||||
import { useLanguage } from '../../context/LanguageContext.jsx';
|
import { useLanguage } from '../../context/LanguageContext.jsx';
|
||||||
import { useTheme } from '../../context/ThemeContext.jsx';
|
import { useTheme } from '../../context/ThemeContext.jsx';
|
||||||
|
|
||||||
// Farbverlauf fuer die Paket-Kacheln: von schlicht/hell (guenstigste Option) bis
|
// Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene
|
||||||
// dunkel/schwarz (teuerste Option). Die FLAECHE bleibt dabei fast neutral grau/schwarz
|
// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen - Basis (weiss) -> Bronze -> Silber
|
||||||
// (nur ein Hauch Gruenstich) - ein voll gesaettigter Gruenverlauf in der Mitte sah nicht
|
// -> Gold -> Platin -> Onyx (fast schwarz, mit Gold-Glanz als Kroenung ganz oben). Jede
|
||||||
// premium aus, sondern kollidierte mit dem gruenen Preistext/Rahmen. Gruen bleibt der
|
// Stufe hat sowohl eine Flaechenfarbe (bg) als auch eine dazu passende Akzentfarbe
|
||||||
// alleinige Akzent (Rahmen + Leucht-Schatten). Interpoliert wird in HSL statt RGB (drei
|
// (Rahmen/Leucht-Schatten/Preis/Icon) - nur der "Kontakt anfragen"-Button bleibt ueberall
|
||||||
// Stuetzpunkte bei t=0/0.5/1, dazwischen linear) - dadurch bekommt JEDES Paket eine
|
// einheitlich markengruen, damit die Kernaktion auf jeder Karte wiedererkennbar bleibt.
|
||||||
// eigene Abstufung statt nur 2-3 fester Stile, egal ob ein Modell 2 oder 12 Pakete hat.
|
// Zwischen zwei Nachbar-Materialien wird in HSL linear interpoliert, sodass JEDES Paket
|
||||||
const LIGHT_STOPS = {
|
// (nicht nur die Materialien selbst) eine eigene Abstufung bekommt.
|
||||||
bg: [
|
const MATERIALS_LIGHT = [
|
||||||
[0, 0, 100],
|
{ bg: [0, 0, 100], accent: [88, 63, 40] }, // Basis
|
||||||
[90, 6, 85],
|
{ bg: [24, 42, 55], accent: [24, 58, 42] }, // Bronze
|
||||||
[95, 10, 7],
|
{ bg: [212, 10, 80], accent: [212, 15, 48] }, // Silber
|
||||||
],
|
{ bg: [45, 60, 60], accent: [42, 75, 42] }, // Gold
|
||||||
border: [
|
{ bg: [196, 16, 85], accent: [200, 22, 52] }, // Platin
|
||||||
[0, 0, 88],
|
{ bg: [225, 20, 6], accent: [45, 70, 55] }, // Onyx
|
||||||
[85, 55, 62],
|
];
|
||||||
[88, 63, 42],
|
const MATERIALS_DARK = [
|
||||||
],
|
{ bg: [0, 0, 9], accent: [88, 55, 48] },
|
||||||
};
|
{ bg: [22, 35, 20], accent: [25, 55, 58] },
|
||||||
const DARK_STOPS = {
|
{ bg: [212, 10, 20], accent: [212, 18, 68] },
|
||||||
bg: [
|
{ bg: [45, 42, 22], accent: [42, 68, 58] },
|
||||||
[0, 0, 9],
|
{ bg: [196, 14, 24], accent: [200, 22, 72] },
|
||||||
[95, 8, 18],
|
{ bg: [225, 22, 4], accent: [45, 75, 68] },
|
||||||
[100, 12, 4],
|
];
|
||||||
],
|
|
||||||
border: [
|
|
||||||
[0, 0, 16],
|
|
||||||
[95, 42, 34],
|
|
||||||
[92, 62, 56],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const hslToRgb = (h, s, l) => {
|
const hslToRgb = (h, s, l) => {
|
||||||
s /= 100;
|
s /= 100;
|
||||||
|
|
@ -49,13 +42,29 @@ const hslToRgb = (h, s, l) => {
|
||||||
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||||
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
||||||
};
|
};
|
||||||
const mixHsl = (c1, c2, t) => [0, 1, 2].map((i) => c1[i] + (c2[i] - c1[i]) * t);
|
const rgbToHsl = (r, g, b) => {
|
||||||
// Liefert sowohl die interpolierte HSL-Lightness (fuer die Textfarben-Entscheidung -
|
(r /= 255), (g /= 255), (b /= 255);
|
||||||
// verlaesslicher als ein RGB-Luminanz-Naeherungswert, da satte Mitteltoene sonst
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
// faelschlich als "hell genug fuer dunklen Text" gelten wuerden) als auch die RGB-Werte.
|
const l = (max + min) / 2;
|
||||||
const stopColor = (stops, t) => {
|
if (max === min) return [0, 0, l * 100];
|
||||||
const hsl = t <= 0.5 ? mixHsl(stops[0], stops[1], t / 0.5) : mixHsl(stops[1], stops[2], (t - 0.5) / 0.5);
|
const d = max - min;
|
||||||
return { rgb: hslToRgb(...hsl), lightness: hsl[2], hsl };
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
const h = max === r ? ((g - b) / d + (g < b ? 6 : 0)) * 60 : max === g ? ((b - r) / d + 2) * 60 : ((r - g) / d + 4) * 60;
|
||||||
|
return [h, s * 100, l * 100];
|
||||||
|
};
|
||||||
|
const mixRgb = (c1, c2, t) => [0, 1, 2].map((i) => c1[i] + (c2[i] - c1[i]) * t);
|
||||||
|
// Findet die zwei Nachbar-Materialien fuer einen Preis-Rang t (0..1) und mischt linear
|
||||||
|
// dazwischen - dadurch bekommt jedes Paket eine eigene Abstufung, nicht nur die 6
|
||||||
|
// Materialien selbst, egal ob ein Modell 2 oder 12 Pakete hat. Gemischt wird in RGB statt
|
||||||
|
// HSL: der Farbton zweier benachbarter Materialien (z.B. Bronze 24° -> Silber 212°) liegt
|
||||||
|
// oft weit auseinander, eine HSL-Interpolation liefe dann mitten durch fremde Farbtoene
|
||||||
|
// (Gelb/Gruen) statt sauber zwischen den beiden Materialien zu vermitteln.
|
||||||
|
const materialRgbAt = (materials, t, key) => {
|
||||||
|
const n = materials.length;
|
||||||
|
const pos = t * (n - 1);
|
||||||
|
const idx = Math.min(Math.floor(pos), n - 2);
|
||||||
|
const frac = pos - idx;
|
||||||
|
return mixRgb(hslToRgb(...materials[idx][key]), hslToRgb(...materials[idx + 1][key]), frac);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ModelPage() {
|
export default function ModelPage() {
|
||||||
|
|
@ -97,10 +106,11 @@ export default function ModelPage() {
|
||||||
const { model, packages } = data;
|
const { model, packages } = data;
|
||||||
|
|
||||||
// Preis-Rang innerhalb dieses Modells bestimmt die optische Wucht der Kachel:
|
// Preis-Rang innerhalb dieses Modells bestimmt die optische Wucht der Kachel:
|
||||||
// die guenstigste Option bleibt schlicht/hell, jede weitere Preisstufe wird
|
// die guenstigste Option bleibt schlicht/weiss, jede weitere Preisstufe durchlaeuft
|
||||||
// kontinuierlich dunkler/leuchtender bis zur teuersten Option. Das braucht keine
|
// eine eigene Materialstufe (Bronze/Silber/Gold/Platin) bis zur Onyx-Kroenung ganz
|
||||||
// zusaetzliche Admin-Einstellung und passt sich automatisch an jede Paketanzahl an.
|
// oben. Das braucht keine zusaetzliche Admin-Einstellung und passt sich automatisch
|
||||||
const stops = theme === 'dark' ? DARK_STOPS : LIGHT_STOPS;
|
// an jede Paketanzahl an.
|
||||||
|
const materials = theme === 'dark' ? MATERIALS_DARK : MATERIALS_LIGHT;
|
||||||
const rankById = new Map(
|
const rankById = new Map(
|
||||||
[...packages].sort((a, b) => a.total_price - b.total_price).map((p, i) => [p.id, i])
|
[...packages].sort((a, b) => a.total_price - b.total_price).map((p, i) => [p.id, i])
|
||||||
);
|
);
|
||||||
|
|
@ -109,31 +119,44 @@ export default function ModelPage() {
|
||||||
const rank = rankById.get(pkg.id) ?? 0;
|
const rank = rankById.get(pkg.id) ?? 0;
|
||||||
const tierT = n <= 1 ? 0 : rank / (n - 1);
|
const tierT = n <= 1 ? 0 : rank / (n - 1);
|
||||||
|
|
||||||
const bg = stopColor(stops.bg, tierT);
|
const bgRgb = materialRgbAt(materials, tierT, 'bg').map(Math.round);
|
||||||
const border = stopColor(stops.border, tierT);
|
const accentRgbFloat = materialRgbAt(materials, tierT, 'accent');
|
||||||
|
const accentRgb = accentRgbFloat.map(Math.round);
|
||||||
|
const bgHsl = rgbToHsl(...bgRgb);
|
||||||
|
const accentHsl = rgbToHsl(...accentRgbFloat);
|
||||||
// Ab hier reicht der Hintergrund nicht mehr zum Kontrastieren mit dunklem Text -
|
// Ab hier reicht der Hintergrund nicht mehr zum Kontrastieren mit dunklem Text -
|
||||||
// satte gruene Mitteltoene brauchen (wie die Buttons) helle statt dunkle Schrift.
|
// Bronze/Gold-Mitteltoene brauchen (wie die Buttons) helle statt dunkle Schrift.
|
||||||
// Diese Entscheidung haengt bewusst an der Basis-Farbe, nicht am Lichtschein unten,
|
// Diese Entscheidung haengt bewusst an der Flaechenfarbe, nicht am Lichtschein
|
||||||
// damit der Text unabhaengig vom Verlauf IMMER gut lesbar bleibt.
|
// unten, damit der Text unabhaengig vom Material IMMER gut lesbar bleibt.
|
||||||
const isDarkCard = bg.lightness < 60;
|
const isDarkCard = bgHsl[2] < 60;
|
||||||
const glowAlpha = (0.05 + tierT * 0.35).toFixed(2);
|
// Preis/Icon brauchen eine kraeftigere, hellere Variante des Materialtons als der
|
||||||
|
// Rahmen - der reine Akzent waere auf dunklem Untergrund selbst zu dunkel zum Lesen.
|
||||||
|
const priceRgb = isDarkCard
|
||||||
|
? hslToRgb(accentHsl[0], Math.min(accentHsl[1] + 10, 90), Math.min(accentHsl[2] + 25, 90))
|
||||||
|
: accentRgb;
|
||||||
|
const glowAlpha = (0.1 + tierT * 0.3).toFixed(2);
|
||||||
|
|
||||||
// Leichter "Lichtschein" von oben rechts statt flacher Flaeche - kommt von oben
|
// Leichter "Lichtschein" von oben rechts statt flacher Flaeche - kommt von oben
|
||||||
// rechts, weil dort nie Text steht (Titel/Preis/Liste sind linksbuendig), damit die
|
// rechts, weil dort nie Text steht (Titel/Preis/Liste sind linksbuendig), damit die
|
||||||
// Aufhellung die Lesbarkeit nirgends beeintraechtigt.
|
// Aufhellung die Lesbarkeit nirgends beeintraechtigt.
|
||||||
const [bh, bs, bl] = bg.hsl;
|
const highlight = hslToRgb(bgHsl[0], Math.max(bgHsl[1] - 8, 0), Math.min(bgHsl[2] + 16, 99));
|
||||||
const highlight = hslToRgb(bh, Math.max(bs - 8, 0), Math.min(bl + 16, 99));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDarkCard,
|
isDarkCard,
|
||||||
|
priceColor: `rgb(${priceRgb.join(', ')})`,
|
||||||
|
iconChipStyle: {
|
||||||
|
backgroundColor: `rgba(${accentRgb.join(', ')}, 0.16)`,
|
||||||
|
color: `rgb(${priceRgb.join(', ')})`,
|
||||||
|
boxShadow: `inset 0 0 0 1px rgba(${accentRgb.join(', ')}, 0.45)`,
|
||||||
|
},
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: `rgb(${bg.rgb.join(', ')})`,
|
backgroundColor: `rgb(${bgRgb.join(', ')})`,
|
||||||
backgroundImage: `radial-gradient(135% 160% at 88% -20%, rgb(${highlight.join(', ')}) 0%, rgb(${bg.rgb.join(', ')}) 55%)`,
|
backgroundImage: `radial-gradient(135% 160% at 88% -20%, rgb(${highlight.join(', ')}) 0%, rgb(${bgRgb.join(', ')}) 55%)`,
|
||||||
borderColor: `rgb(${border.rgb.join(', ')})`,
|
borderColor: `rgb(${accentRgb.join(', ')})`,
|
||||||
borderWidth: tierT > 0.12 ? 2 : 1,
|
borderWidth: tierT > 0.08 ? 2 : 1,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
tierT > 0.08
|
tierT > 0.04
|
||||||
? `0 ${Math.round(6 + tierT * 14)}px ${Math.round(20 + tierT * 45)}px -10px rgba(107, 166, 38, ${glowAlpha})`
|
? `0 ${Math.round(6 + tierT * 14)}px ${Math.round(20 + tierT * 45)}px -10px rgba(${accentRgb.join(', ')}, ${glowAlpha})`
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -167,7 +190,7 @@ export default function ModelPage() {
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{packages.map((pkg) => {
|
{packages.map((pkg) => {
|
||||||
const { isDarkCard, style } = styleOf(pkg);
|
const { isDarkCard, style, priceColor, iconChipStyle } = styleOf(pkg);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={pkg.id} style={style} className="relative flex flex-col rounded-xl border p-6">
|
<div key={pkg.id} style={style} className="relative flex flex-col rounded-xl border p-6">
|
||||||
|
|
@ -178,13 +201,7 @@ export default function ModelPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pkg.icon_name && (
|
{pkg.icon_name && (
|
||||||
<div
|
<div style={iconChipStyle} className="mb-3 flex h-11 w-11 items-center justify-center rounded-full">
|
||||||
className={`mb-3 flex h-11 w-11 items-center justify-center rounded-full ${
|
|
||||||
isDarkCard
|
|
||||||
? 'bg-brand-500/15 text-brand-400 ring-1 ring-brand-500/40'
|
|
||||||
: 'bg-brand-100 text-brand-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<DynamicIcon name={pkg.icon_name} className="h-6 w-6" />
|
<DynamicIcon name={pkg.icon_name} className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -202,7 +219,7 @@ export default function ModelPage() {
|
||||||
<p className={`text-xs uppercase tracking-wide ${isDarkCard ? 'text-neutral-300' : 'text-neutral-400'}`}>
|
<p className={`text-xs uppercase tracking-wide ${isDarkCard ? 'text-neutral-300' : 'text-neutral-400'}`}>
|
||||||
{t('modelPage.totalPrice')}
|
{t('modelPage.totalPrice')}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-2xl font-extrabold ${isDarkCard ? 'text-brand-400' : 'text-brand-600'}`}>
|
<p style={{ color: priceColor }} className="text-2xl font-extrabold">
|
||||||
{formatPrice(pkg.total_price)}
|
{formatPrice(pkg.total_price)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue