Skip to content

Commit a4d9519

Browse files
os-zhuangclaude
andcommitted
fix(fields): honor hex option colors in status/select badges
Select/status badge colors silently ignored the field option's `color` when it was a HEX value (which is how objects almost always declare it). getBadgeColorClasses only matched NAMED palette keys (red/blue/…) against BADGE_COLOR_MAP, so a hex like '#8B5CF6' fell through to a semantic-by-value / hash heuristic — producing colors that contradicted the author's intent: • In Review (#8B5CF6 purple) → rendered alarming RED • Backlog (#94A3B8 grey) → rendered yellow • Medium (#3B82F6 blue) → rendered amber Add hexToPaletteName() (HSL hue-bucket, low-saturation → gray) + resolveColorName() and use them in getBadgeColorClasses, getSemanticColorName, and the dot path. Hex option colors now resolve to the nearest palette color and render as the author intended; named colors, the value-semantic map, and the hash fallback are unchanged. Fixes wrong/alarming status colors across every app that declares option colors as hex (the common case). Verified on the Tasks list (:5181): In Review purple, Backlog grey, Medium blue, Done green, Urgent red — all matching task.object.ts. 142 badge/color/select tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0928713 commit a4d9519

1 file changed

Lines changed: 48 additions & 3 deletions

File tree

packages/fields/src/index.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,52 @@ function hashToColor(value: string): string {
779779
return BADGE_FALLBACK_PALETTE[idx];
780780
}
781781

782+
/**
783+
* Map a hex color (e.g. '#8B5CF6') to the nearest named palette color the
784+
* badge/dot maps understand. Object field options almost always declare colors
785+
* as HEX, so without this the explicit author color is ignored and a semantic/
786+
* hash heuristic takes over (e.g. a purple 'In Review' rendered alarming-red).
787+
* Low-saturation hexes resolve to 'gray'; otherwise bucket by hue.
788+
*/
789+
function hexToPaletteName(hex: string): string | undefined {
790+
const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
791+
if (!m) return undefined;
792+
let h = m[1];
793+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
794+
const r = parseInt(h.slice(0, 2), 16) / 255;
795+
const g = parseInt(h.slice(2, 4), 16) / 255;
796+
const b = parseInt(h.slice(4, 6), 16) / 255;
797+
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
798+
const l = (max + min) / 2;
799+
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
800+
if (sat < 0.22) return 'gray';
801+
let hue: number;
802+
if (max === r) hue = (((g - b) / d) % 6 + 6) % 6;
803+
else if (max === g) hue = (b - r) / d + 2;
804+
else hue = (r - g) / d + 4;
805+
hue *= 60;
806+
if (hue >= 345 || hue < 15) return 'red';
807+
if (hue < 30) return 'orange';
808+
if (hue < 60) return 'yellow';
809+
if (hue < 170) return 'green';
810+
if (hue < 238) return 'blue';
811+
if (hue < 250) return 'indigo';
812+
if (hue < 295) return 'purple';
813+
return 'pink';
814+
}
815+
816+
/** Normalize an option color to a named palette key: pass known names through,
817+
* resolve hex to the nearest palette color, else undefined. */
818+
function resolveColorName(color?: string): string | undefined {
819+
if (!color) return undefined;
820+
if (BADGE_COLOR_MAP[color]) return color;
821+
if (color.charAt(0) === '#') return hexToPaletteName(color);
822+
return undefined;
823+
}
824+
782825
export function getBadgeColorClasses(color?: string, val?: unknown): string {
783-
if (color && BADGE_COLOR_MAP[color]) return BADGE_COLOR_MAP[color];
826+
const named = resolveColorName(color);
827+
if (named && BADGE_COLOR_MAP[named]) return BADGE_COLOR_MAP[named];
784828
if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
785829
const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
786830
const semantic = SEMANTIC_COLOR_MAP[key];
@@ -800,7 +844,8 @@ export function getBadgeColorClasses(color?: string, val?: unknown): string {
800844
* supplied so the caller can fall back to its own default.
801845
*/
802846
export function getSemanticColorName(color?: string, val?: unknown): string | undefined {
803-
if (color && BADGE_COLOR_MAP[color]) return color;
847+
const named = resolveColorName(color);
848+
if (named && BADGE_COLOR_MAP[named]) return named;
804849
if (val == null || val === '') return undefined;
805850
const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
806851
const semantic = SEMANTIC_COLOR_MAP[key];
@@ -872,7 +917,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
872917
if (appearance === 'dot') {
873918
// Resolve a real CSS color for the dot. Prefer explicit option color,
874919
// then semantic mapping for the value, then deterministic palette.
875-
const colorName = option?.color
920+
const colorName = resolveColorName(option?.color)
876921
|| SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
877922
|| hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
878923
const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;

0 commit comments

Comments
 (0)