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.
This commit is contained in:
Arthur Belleville 2026-05-10 14:18:33 +02:00
parent c780dd1625
commit 3232309388
No known key found for this signature in database
9 changed files with 244 additions and 14 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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;

View file

@ -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, `<path d="M11 20A7 7 0 0 1 4 13V6a1 1 0 0 1 1-1h7a7 7 0 0 1 7 7v0a8 8 0 0 1-8 8Z"></path>`) {
t.Fatalf("expected leaf icon markup, got %q", html)
}
}
func TestTabloListRowDoesNotRenderSpacerBetweenEditAndDelete(t *testing.T) {
component := TabloListRow(TabloCardView{
ID: "11111111-1111-1111-1111-111111111111",

View file

@ -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

View file

@ -125,6 +125,54 @@ templ SidebarIcon(kind string) {
<path d="M11 3 8 9l4 13 4-13-3-6"></path>
<path d="M2 9h20"></path>
</svg>
case "leaf":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 20A7 7 0 0 1 4 13V6a1 1 0 0 1 1-1h7a7 7 0 0 1 7 7v0a8 8 0 0 1-8 8Z"></path>
<path d="M12 10 4 18"></path>
</svg>
case "flame":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8.5 14.5A5.5 5.5 0 1 0 19 18c0-1.57-.52-3.09-1.48-4.3C16.1 12 14 10.82 14 7.5c0-1.5.5-3 1.5-4-4 1-7 4.5-7 8.5 0 1.61.49 3.16 1.4 4.45"></path>
<path d="M12 22c2.21 0 4-1.79 4-4 0-1.5-.83-2.8-2.05-3.49-.61 1.03-1.6 1.83-2.82 2.2"></path>
</svg>
case "star":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m12 3 2.9 5.88 6.5.95-4.7 4.58 1.11 6.47L12 17.77 6.19 20.88l1.11-6.47-4.7-4.58 6.5-.95Z"></path>
</svg>
case "compass":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="m16.24 7.76-2.12 6.36-6.36 2.12 2.12-6.36z"></path>
</svg>
case "heart":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m12 21-1.45-1.32C5.4 15.02 2 11.93 2 8.1 2 5 4.42 2.5 7.5 2.5c1.74 0 3.41.81 4.5 2.09A6 6 0 0 1 16.5 2.5C19.58 2.5 22 5 22 8.1c0 3.83-3.4 6.92-8.55 11.58Z"></path>
</svg>
case "waves":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 6c1.5 1.5 3 2 4.5 2S9.5 7.5 11 6s3-2 4.5-2S18.5 4.5 20 6s3 2 4 2"></path>
<path d="M2 12c1.5 1.5 3 2 4.5 2s3-.5 4.5-2 3-2 4.5-2 3 .5 4.5 2 3 2 4 2"></path>
<path d="M2 18c1.5 1.5 3 2 4.5 2s3-.5 4.5-2 3-2 4.5-2 3 .5 4.5 2 3 2 4 2"></path>
</svg>
case "sun":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
case "sparkles":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.94 3.06 12 8l2.06-4.94L19 1l-2.06 4.94L22 8l-5.06 2.06L14.88 15 12 10.06 9.12 15 7.06 10.06 2 8l5.06-2.06Z"></path>
<path d="M5 19v-2"></path>
<path d="M19 19v-2"></path>
<path d="M12 22v-2"></path>
</svg>
default:
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path>

View file

@ -157,8 +157,48 @@ func SidebarIcon(kind string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "leaf":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M11 20A7 7 0 0 1 4 13V6a1 1 0 0 1 1-1h7a7 7 0 0 1 7 7v0a8 8 0 0 1-8 8Z\"></path> <path d=\"M12 10 4 18\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "flame":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M8.5 14.5A5.5 5.5 0 1 0 19 18c0-1.57-.52-3.09-1.48-4.3C16.1 12 14 10.82 14 7.5c0-1.5.5-3 1.5-4-4 1-7 4.5-7 8.5 0 1.61.49 3.16 1.4 4.45\"></path> <path d=\"M12 22c2.21 0 4-1.79 4-4 0-1.5-.83-2.8-2.05-3.49-.61 1.03-1.6 1.83-2.82 2.2\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "star":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m12 3 2.9 5.88 6.5.95-4.7 4.58 1.11 6.47L12 17.77 6.19 20.88l1.11-6.47-4.7-4.58 6.5-.95Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "compass":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"m16.24 7.76-2.12 6.36-6.36 2.12 2.12-6.36z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "heart":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m12 21-1.45-1.32C5.4 15.02 2 11.93 2 8.1 2 5 4.42 2.5 7.5 2.5c1.74 0 3.41.81 4.5 2.09A6 6 0 0 1 16.5 2.5C19.58 2.5 22 5 22 8.1c0 3.83-3.4 6.92-8.55 11.58Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "waves":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M2 6c1.5 1.5 3 2 4.5 2S9.5 7.5 11 6s3-2 4.5-2S18.5 4.5 20 6s3 2 4 2\"></path> <path d=\"M2 12c1.5 1.5 3 2 4.5 2s3-.5 4.5-2 3-2 4.5-2 3 .5 4.5 2 3 2 4 2\"></path> <path d=\"M2 18c1.5 1.5 3 2 4.5 2s3-.5 4.5-2 3-2 4.5-2 3 .5 4.5 2 3 2 4 2\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "sun":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"4\"></circle> <path d=\"M12 2v2\"></path> <path d=\"M12 20v2\"></path> <path d=\"m4.93 4.93 1.41 1.41\"></path> <path d=\"m17.66 17.66 1.41 1.41\"></path> <path d=\"M2 12h2\"></path> <path d=\"M20 12h2\"></path> <path d=\"m6.34 17.66-1.41 1.41\"></path> <path d=\"m19.07 4.93-1.41 1.41\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "sparkles":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M9.94 3.06 12 8l2.06-4.94L19 1l-2.06 4.94L22 8l-5.06 2.06L14.88 15 12 10.06 9.12 15 7.06 10.06 2 8l5.06-2.06Z\"></path> <path d=\"M5 19v-2\"></path> <path d=\"M19 19v-2\"></path> <path d=\"M12 22v-2\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -394,7 +394,7 @@ input {
.ui-button {
align-items: center;
border: 0;
border-radius: 0.7rem;
border-radius: 0.35rem;
cursor: pointer;
display: inline-flex;
font-weight: 600;

View file

@ -3,13 +3,18 @@
:root, :host {
--color-red-50: oklch(97.1% 0.013 17.38);
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-700: oklch(50.5% 0.213 27.518);
--color-orange-500: oklch(70.5% 0.213 47.604);
--color-yellow-500: oklch(79.5% 0.184 86.047);
--color-green-50: oklch(98.2% 0.018 155.826);
--color-green-200: oklch(92.5% 0.084 155.995);
--color-green-400: oklch(79.2% 0.209 151.711);
--color-green-500: oklch(72.3% 0.219 149.579);
--color-green-600: oklch(62.7% 0.194 149.214);
--color-green-800: oklch(44.8% 0.119 151.328);
--color-green-950: oklch(26.6% 0.065 152.934);
--color-teal-500: oklch(70.4% 0.14 182.503);
--color-cyan-500: oklch(71.5% 0.143 215.221);
--color-blue-50: oklch(97% 0.014 254.604);
--color-blue-200: oklch(88.2% 0.059 254.128);
@ -18,11 +23,13 @@
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-800: oklch(42.4% 0.199 265.638);
--color-blue-950: oklch(28.2% 0.091 267.935);
--color-indigo-500: oklch(58.5% 0.233 277.117);
--color-purple-50: oklch(97.7% 0.014 308.299);
--color-purple-400: oklch(71.4% 0.203 305.504);
--color-purple-500: oklch(62.7% 0.265 303.9);
--color-purple-600: oklch(55.8% 0.288 302.321);
--color-purple-950: oklch(29.1% 0.149 302.717);
--color-pink-500: oklch(65.6% 0.241 354.308);
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531);
@ -310,6 +317,18 @@
.bg-green-50 {
background-color: var(--color-green-50);
}
.bg-green-500 {
background-color: var(--color-green-500);
}
.bg-indigo-500 {
background-color: var(--color-indigo-500);
}
.bg-orange-500 {
background-color: var(--color-orange-500);
}
.bg-pink-500 {
background-color: var(--color-pink-500);
}
.bg-purple-50 {
background-color: var(--color-purple-50);
}
@ -322,6 +341,12 @@
.bg-red-50 {
background-color: var(--color-red-50);
}
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-teal-500 {
background-color: var(--color-teal-500);
}
.bg-white {
background-color: var(--color-white);
}
@ -333,6 +358,9 @@
}
}
}
.bg-yellow-500 {
background-color: var(--color-yellow-500);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}