From 3232309388a15de5cd5cefd4943ce835801aa99c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 14:18:33 +0200 Subject: [PATCH] Make tablo icon selection dynamic based on color using nearest palette match Instead of selecting icons based on tablo name length, compute the closest matching icon from a predefined palette by comparing hex color values. This ensures consistent icon-color pairing and better visual harmony. --- go-backend/internal/web/handlers/tablos.go | 83 ++++++++++++++++--- .../internal/web/handlers/tablos_test.go | 27 ++++++ go-backend/internal/web/ui/button.css | 2 +- .../web/views/dashboard_components_test.go | 23 +++++ go-backend/internal/web/views/home.go | 3 +- go-backend/internal/web/views/icons.templ | 48 +++++++++++ go-backend/internal/web/views/icons_templ.go | 42 +++++++++- go-backend/static/styles.css | 2 +- go-backend/static/tailwind.css | 28 +++++++ 9 files changed, 244 insertions(+), 14 deletions(-) diff --git a/go-backend/internal/web/handlers/tablos.go b/go-backend/internal/web/handlers/tablos.go index a1cc9a4..3d1811c 100644 --- a/go-backend/internal/web/handlers/tablos.go +++ b/go-backend/internal/web/handlers/tablos.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "strings" "time" @@ -23,6 +24,29 @@ var tabloColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) const defaultTabloColor = "#3B82F6" const tabloColorValidationMessage = "La couleur du projet doit être un code hexadécimal au format #RRGGBB" +type tabloPaletteEntry struct { + icon string + bg string + fg string + accent string + r int + g int + b int +} + +var tabloIconPalette = []tabloPaletteEntry{ + {icon: "bolt", bg: "bg-blue-500", fg: "text-white", accent: "blue", r: 59, g: 130, b: 246}, + {icon: "leaf", bg: "bg-green-500", fg: "text-white", accent: "green", r: 34, g: 197, b: 94}, + {icon: "gem", bg: "bg-purple-500", fg: "text-white", accent: "purple", r: 168, g: 85, b: 247}, + {icon: "flame", bg: "bg-red-500", fg: "text-white", accent: "red", r: 239, g: 68, b: 68}, + {icon: "star", bg: "bg-yellow-500", fg: "text-gray-700", accent: "yellow", r: 234, g: 179, b: 8}, + {icon: "compass", bg: "bg-indigo-500", fg: "text-white", accent: "indigo", r: 99, g: 102, b: 241}, + {icon: "heart", bg: "bg-pink-500", fg: "text-white", accent: "pink", r: 236, g: 72, b: 153}, + {icon: "waves", bg: "bg-teal-500", fg: "text-white", accent: "teal", r: 20, g: 184, b: 166}, + {icon: "sun", bg: "bg-orange-500", fg: "text-white", accent: "orange", r: 249, g: 115, b: 22}, + {icon: "sparkles", bg: "bg-cyan-500", fg: "text-gray-700", accent: "cyan", r: 6, g: 182, b: 212}, +} + type TabloStatus = tablomodel.Status const ( @@ -455,12 +479,13 @@ func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.Ta items := make([]views.TabloCardView, 0, len(tablos)) for _, tablo := range tablos { statusLabel, statusClass, progress, statusTone := tabloStatusPresentation(tablo.Status) - iconKind, bgClass, fgClass, accent := tabloIconPresentation(tablo.Name) + color := storedTabloColor(tablo.Color) + iconKind, bgClass, fgClass, accent := tabloIconPresentation(color) items = append(items, views.TabloCardView{ ID: tablo.ID.String(), Name: tablo.Name, - Color: storedTabloColor(tablo.Color), + Color: color, Status: string(tablo.Status), StatusLabel: statusLabel, StatusClass: statusClass, @@ -512,15 +537,53 @@ func tabloStatusPresentation(status TabloStatus) (string, string, int, string) { } } -func tabloIconPresentation(name string) (string, string, string, string) { - switch len(strings.TrimSpace(name)) % 3 { - case 1: - return "gem", "bg-purple-500", "text-white", "purple" - case 2: - return "sparkles", "bg-cyan-500", "text-gray-700", "red" - default: - return "bolt", "bg-blue-500", "text-white", "blue" +func tabloIconPresentation(color string) (string, string, string, string) { + r, g, b, ok := parseHexColor(color) + if !ok { + fallback := tabloIconPalette[0] + return fallback.icon, fallback.bg, fallback.fg, fallback.accent } + + best := tabloIconPalette[0] + bestDistance := colorDistanceSquared(r, g, b, best.r, best.g, best.b) + for _, entry := range tabloIconPalette[1:] { + distance := colorDistanceSquared(r, g, b, entry.r, entry.g, entry.b) + if distance < bestDistance { + best = entry + bestDistance = distance + } + } + + return best.icon, best.bg, best.fg, best.accent +} + +func parseHexColor(color string) (int, int, int, bool) { + trimmed := strings.TrimSpace(color) + if len(trimmed) != 7 || trimmed[0] != '#' { + return 0, 0, 0, false + } + + r, err := strconv.ParseInt(trimmed[1:3], 16, 0) + if err != nil { + return 0, 0, 0, false + } + g, err := strconv.ParseInt(trimmed[3:5], 16, 0) + if err != nil { + return 0, 0, 0, false + } + b, err := strconv.ParseInt(trimmed[5:7], 16, 0) + if err != nil { + return 0, 0, 0, false + } + + return int(r), int(g), int(b), true +} + +func colorDistanceSquared(r1 int, g1 int, b1 int, r2 int, g2 int, b2 int) int { + dr := r1 - r2 + dg := g1 - g2 + db := b1 - b2 + return dr*dr + dg*dg + db*db } func formatFrenchDate(value time.Time) string { diff --git a/go-backend/internal/web/handlers/tablos_test.go b/go-backend/internal/web/handlers/tablos_test.go index 928d560..95e5cdf 100644 --- a/go-backend/internal/web/handlers/tablos_test.go +++ b/go-backend/internal/web/handlers/tablos_test.go @@ -493,6 +493,33 @@ func TestFormatCardDateUsesFrenchMonthNames(t *testing.T) { } } +func TestTabloIconPresentationUsesClosestPaletteColor(t *testing.T) { + for _, tt := range []struct { + name string + color string + icon string + }{ + {name: "blue maps to bolt", color: "#3B82F6", icon: "bolt"}, + {name: "green maps to leaf", color: "#22C55E", icon: "leaf"}, + {name: "purple maps to gem", color: "#A855F7", icon: "gem"}, + {name: "red maps to flame", color: "#EF4444", icon: "flame"}, + {name: "yellow maps to star", color: "#EAB308", icon: "star"}, + {name: "indigo maps to compass", color: "#6366F1", icon: "compass"}, + {name: "pink maps to heart", color: "#EC4899", icon: "heart"}, + {name: "teal maps to waves", color: "#14B8A6", icon: "waves"}, + {name: "orange maps to sun", color: "#F97316", icon: "sun"}, + {name: "cyan maps to sparkles", color: "#06B6D4", icon: "sparkles"}, + {name: "nearby blue still maps to bolt", color: "#4F86F7", icon: "bolt"}, + } { + t.Run(tt.name, func(t *testing.T) { + icon, _, _, _ := tabloIconPresentation(tt.color) + if icon != tt.icon { + t.Fatalf("expected icon %q for color %q, got %q", tt.icon, tt.color, icon) + } + }) + } +} + func TestGetTablosPageGridUsesProjectCardMarkup(t *testing.T) { repo := NewInMemoryAuthRepository() handler := NewAuthHandler(repo) diff --git a/go-backend/internal/web/ui/button.css b/go-backend/internal/web/ui/button.css index 07f045b..95fb45e 100644 --- a/go-backend/internal/web/ui/button.css +++ b/go-backend/internal/web/ui/button.css @@ -1,7 +1,7 @@ .ui-button { align-items: center; border: 0; - border-radius: 0.7rem; + border-radius: 0.35rem; cursor: pointer; display: inline-flex; font-weight: 600; diff --git a/go-backend/internal/web/views/dashboard_components_test.go b/go-backend/internal/web/views/dashboard_components_test.go index 3be1984..7d67bd5 100644 --- a/go-backend/internal/web/views/dashboard_components_test.go +++ b/go-backend/internal/web/views/dashboard_components_test.go @@ -60,6 +60,29 @@ func TestOverviewProjectsSectionRendersColorAndEditAction(t *testing.T) { } } +func TestTabloListRowRendersLeafIconKind(t *testing.T) { + component := TabloListRow(TabloCardView{ + ID: "11111111-1111-1111-1111-111111111111", + Name: "Palette", + Color: "#22C55E", + StatusLabel: "À faire", + StatusTone: "info", + Progress: 0, + ProgressLabel: "0%", + CreatedAtLabel: "10 mai 2026", + DeleteRequestURL: "/tablos/11111111-1111-1111-1111-111111111111", + EditRequestURL: "/tablos/11111111-1111-1111-1111-111111111111/edit", + IconKind: "leaf", + Initial: "P", + }) + + html := renderViewToString(t, component) + + if !strings.Contains(html, ``) { + t.Fatalf("expected leaf icon markup, got %q", html) + } +} + func TestTabloListRowDoesNotRenderSpacerBetweenEditAndDelete(t *testing.T) { component := TabloListRow(TabloCardView{ ID: "11111111-1111-1111-1111-111111111111", diff --git a/go-backend/internal/web/views/home.go b/go-backend/internal/web/views/home.go index 2818aa3..a7de2eb 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -5,9 +5,10 @@ import ( "strings" "time" - "github.com/a-h/templ" tablomodel "xtablo-backend/internal/tablos" "xtablo-backend/internal/web/dates" + + "github.com/a-h/templ" ) const overviewProjectsPreviewLimit = 6 diff --git a/go-backend/internal/web/views/icons.templ b/go-backend/internal/web/views/icons.templ index 19a2373..f5834c6 100644 --- a/go-backend/internal/web/views/icons.templ +++ b/go-backend/internal/web/views/icons.templ @@ -125,6 +125,54 @@ templ SidebarIcon(kind string) { + case "leaf": + + case "flame": + + case "star": + + case "compass": + + case "heart": + + case "waves": + + case "sun": + + case "sparkles": + default: