feat(équipes): la pill d'en-tête prend la couleur de l'équipe en fond, texte encre ou crème au meilleur contraste WCAGUnverified
Claude-Session: https://claude.ai/code/session_01FPKHKGfqvnvXKY9tNhYmkh
1401cf3 parent: f59263c modified
assets/style.css +5 -3 | @@ -148,16 +148,18 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | ||
| 148 | 148 | |
| 149 | 149 | .app-header { padding: var(--space-6) var(--space-4) var(--space-3); } |
| 150 | 150 | .app-topline { display: flex; align-items: center; gap: var(--space-2); margin-bottom: 4px; } |
| 151 | +/* Fond = couleur de l'équipe (hash de l'id) et texte au meilleur contraste, | |
| 152 | + tous deux posés en style inline par le serveur. */ | |
| 151 | 153 | .team-pill { |
| 152 | 154 | display: inline-flex; align-items: center; gap: 8px; font-size: 14px; |
| 153 | - padding: 9px 15px 9px 12px; border-radius: 999px; | |
| 155 | + padding: 9px 15px; border-radius: 999px; | |
| 154 | 156 | background: var(--color-accent-2-200); color: var(--color-accent-2-900); |
| 155 | 157 | } |
| 156 | -/* Pastille couleur d'équipe (hash de l'id, posée en style inline). */ | |
| 158 | +/* Pastille couleur d'équipe (sheet « Changer d'équipe »). */ | |
| 157 | 159 | .team-dot { width: 12px; height: 12px; border-radius: 999px; flex: none; } |
| 158 | 160 | /* Pill-sélecteur (plusieurs équipes) : ouvre la sheet #equipes. */ |
| 159 | 161 | a.team-pill { text-decoration: none; cursor: pointer; } |
| 160 | -a.team-pill:hover { background: var(--color-accent-2-300, var(--color-accent-2-200)); } | |
| 162 | +a.team-pill:hover { filter: brightness(1.06); } | |
| 161 | 163 | .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; } |
| 162 | 164 | .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } |
| 163 | 165 | |
| @@ -148,16 +148,18 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | |||
| 148 | 148 | ||
| 149 | .app-header { padding: var(--space-6) var(--space-4) var(--space-3); } | 149 | .app-header { padding: var(--space-6) var(--space-4) var(--space-3); } |
| 150 | .app-topline { display: flex; align-items: center; gap: var(--space-2); margin-bottom: 4px; } | 150 | .app-topline { display: flex; align-items: center; gap: var(--space-2); margin-bottom: 4px; } |
| 151 | +/* Fond = couleur de l'équipe (hash de l'id) et texte au meilleur contraste, | ||
| 152 | + tous deux posés en style inline par le serveur. */ | ||
| 151 | .team-pill { | 153 | .team-pill { |
| 152 | display: inline-flex; align-items: center; gap: 8px; font-size: 14px; | 154 | display: inline-flex; align-items: center; gap: 8px; font-size: 14px; |
| 153 | - padding: 9px 15px 9px 12px; border-radius: 999px; | 155 | + padding: 9px 15px; border-radius: 999px; |
| 154 | background: var(--color-accent-2-200); color: var(--color-accent-2-900); | 156 | background: var(--color-accent-2-200); color: var(--color-accent-2-900); |
| 155 | } | 157 | } |
| 156 | -/* Pastille couleur d'équipe (hash de l'id, posée en style inline). */ | 158 | +/* Pastille couleur d'équipe (sheet « Changer d'équipe »). */ |
| 157 | .team-dot { width: 12px; height: 12px; border-radius: 999px; flex: none; } | 159 | .team-dot { width: 12px; height: 12px; border-radius: 999px; flex: none; } |
| 158 | /* Pill-sélecteur (plusieurs équipes) : ouvre la sheet #equipes. */ | 160 | /* Pill-sélecteur (plusieurs équipes) : ouvre la sheet #equipes. */ |
| 159 | a.team-pill { text-decoration: none; cursor: pointer; } | 161 | a.team-pill { text-decoration: none; cursor: pointer; } |
| 160 | -a.team-pill:hover { background: var(--color-accent-2-300, var(--color-accent-2-200)); } | 162 | +a.team-pill:hover { filter: brightness(1.06); } |
| 161 | .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; } | 163 | .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; } |
| 162 | .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } | 164 | .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } |
| 163 | 165 | ||
modified
src/charts.rs +40 -0 | @@ -30,6 +30,35 @@ pub fn team_color(team_id: Uuid) -> &'static str { | ||
| 30 | 30 | PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize] |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | +/// Couleur de texte au meilleur contraste WCAG sur un fond donné : l'encre ou | |
| 34 | +/// la crème du design Organic (tokens --color-text / --color-bg). | |
| 35 | +pub fn contrast_text(bg_hex: &str) -> &'static str { | |
| 36 | + const DARK: &str = "#201e1d"; | |
| 37 | + const LIGHT: &str = "#f5ead8"; | |
| 38 | + let bg = luminance(bg_hex); | |
| 39 | + if ratio(bg, luminance(DARK)) >= ratio(bg, luminance(LIGHT)) { | |
| 40 | + DARK | |
| 41 | + } else { | |
| 42 | + LIGHT | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +/// Luminance relative sRGB (WCAG 2) d'une couleur « #rrggbb ». | |
| 47 | +fn luminance(hex: &str) -> f64 { | |
| 48 | + let chan = |i: usize| { | |
| 49 | + let v = u8::from_str_radix(hex.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f64 | |
| 50 | + / 255.0; | |
| 51 | + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } | |
| 52 | + }; | |
| 53 | + 0.2126 * chan(1) + 0.7152 * chan(3) + 0.0722 * chan(5) | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// Ratio de contraste WCAG entre deux luminances. | |
| 57 | +fn ratio(a: f64, b: f64) -> f64 { | |
| 58 | + let (hi, lo) = if a > b { (a, b) } else { (b, a) }; | |
| 59 | + (hi + 0.05) / (lo + 0.05) | |
| 60 | +} | |
| 61 | + | |
| 33 | 62 | /// « 45 min », « 2 h », « 12 h 30 » |
| 34 | 63 | pub fn fmt_minutes(total: i64) -> String { |
| 35 | 64 | let (h, m) = (total / 60, total % 60); |
| @@ -274,4 +303,15 @@ mod tests { | ||
| 274 | 303 | assert!(PALETTE.contains(&team_color(id))); |
| 275 | 304 | assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]); |
| 276 | 305 | } |
| 306 | + | |
| 307 | + #[test] | |
| 308 | + fn contrast_text_picks_readable_ink() { | |
| 309 | + assert_eq!(contrast_text("#56633f"), "#f5ead8"); // olive sombre → crème | |
| 310 | + assert_eq!(contrast_text("#6d4a33"), "#f5ead8"); // cacao → crème | |
| 311 | + assert_eq!(contrast_text("#ccdbb2"), "#201e1d"); // vert pâle → encre | |
| 312 | + assert_eq!(contrast_text("#e3c088"), "#201e1d"); // sable → encre | |
| 313 | + for bg in PALETTE { | |
| 314 | + assert!(["#201e1d", "#f5ead8"].contains(&contrast_text(bg))); | |
| 315 | + } | |
| 316 | + } | |
| 277 | 317 | } |
| @@ -30,6 +30,35 @@ pub fn team_color(team_id: Uuid) -> &'static str { | |||
| 30 | PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize] | 30 | PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize] |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | +/// Couleur de texte au meilleur contraste WCAG sur un fond donné : l'encre ou | ||
| 34 | +/// la crème du design Organic (tokens --color-text / --color-bg). | ||
| 35 | +pub fn contrast_text(bg_hex: &str) -> &'static str { | ||
| 36 | + const DARK: &str = "#201e1d"; | ||
| 37 | + const LIGHT: &str = "#f5ead8"; | ||
| 38 | + let bg = luminance(bg_hex); | ||
| 39 | + if ratio(bg, luminance(DARK)) >= ratio(bg, luminance(LIGHT)) { | ||
| 40 | + DARK | ||
| 41 | + } else { | ||
| 42 | + LIGHT | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +/// Luminance relative sRGB (WCAG 2) d'une couleur « #rrggbb ». | ||
| 47 | +fn luminance(hex: &str) -> f64 { | ||
| 48 | + let chan = |i: usize| { | ||
| 49 | + let v = u8::from_str_radix(hex.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f64 | ||
| 50 | + / 255.0; | ||
| 51 | + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } | ||
| 52 | + }; | ||
| 53 | + 0.2126 * chan(1) + 0.7152 * chan(3) + 0.0722 * chan(5) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +/// Ratio de contraste WCAG entre deux luminances. | ||
| 57 | +fn ratio(a: f64, b: f64) -> f64 { | ||
| 58 | + let (hi, lo) = if a > b { (a, b) } else { (b, a) }; | ||
| 59 | + (hi + 0.05) / (lo + 0.05) | ||
| 60 | +} | ||
| 61 | + | ||
| 33 | /// « 45 min », « 2 h », « 12 h 30 » | 62 | /// « 45 min », « 2 h », « 12 h 30 » |
| 34 | pub fn fmt_minutes(total: i64) -> String { | 63 | pub fn fmt_minutes(total: i64) -> String { |
| 35 | let (h, m) = (total / 60, total % 60); | 64 | let (h, m) = (total / 60, total % 60); |
| @@ -274,4 +303,15 @@ mod tests { | |||
| 274 | assert!(PALETTE.contains(&team_color(id))); | 303 | assert!(PALETTE.contains(&team_color(id))); |
| 275 | assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]); | 304 | assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]); |
| 276 | } | 305 | } |
| 306 | + | ||
| 307 | + #[test] | ||
| 308 | + fn contrast_text_picks_readable_ink() { | ||
| 309 | + assert_eq!(contrast_text("#56633f"), "#f5ead8"); // olive sombre → crème | ||
| 310 | + assert_eq!(contrast_text("#6d4a33"), "#f5ead8"); // cacao → crème | ||
| 311 | + assert_eq!(contrast_text("#ccdbb2"), "#201e1d"); // vert pâle → encre | ||
| 312 | + assert_eq!(contrast_text("#e3c088"), "#201e1d"); // sable → encre | ||
| 313 | + for bg in PALETTE { | ||
| 314 | + assert!(["#201e1d", "#f5ead8"].contains(&contrast_text(bg))); | ||
| 315 | + } | ||
| 316 | + } | ||
| 277 | } | 317 | } |
modified
src/render.rs +3 -1 | @@ -47,8 +47,10 @@ pub struct Shell { | ||
| 47 | 47 | pub username: String, |
| 48 | 48 | /// Nom de l'équipe active (pill de l'en-tête). |
| 49 | 49 | pub team_name: String, |
| 50 | - /// Couleur de l'équipe active (pastille dans la pill). | |
| 50 | + /// Couleur de l'équipe active (fond de la pill d'en-tête). | |
| 51 | 51 | pub team_color: &'static str, |
| 52 | + /// Encre ou crème selon le meilleur contraste sur team_color. | |
| 53 | + pub team_text: &'static str, | |
| 52 | 54 | /// "add" | "stats" | "times" | "settings" |
| 53 | 55 | pub tab: &'static str, |
| 54 | 56 | pub toast: Option<String>, |
| @@ -47,8 +47,10 @@ pub struct Shell { | |||
| 47 | pub username: String, | 47 | pub username: String, |
| 48 | /// Nom de l'équipe active (pill de l'en-tête). | 48 | /// Nom de l'équipe active (pill de l'en-tête). |
| 49 | pub team_name: String, | 49 | pub team_name: String, |
| 50 | - /// Couleur de l'équipe active (pastille dans la pill). | 50 | + /// Couleur de l'équipe active (fond de la pill d'en-tête). |
| 51 | pub team_color: &'static str, | 51 | pub team_color: &'static str, |
| 52 | + /// Encre ou crème selon le meilleur contraste sur team_color. | ||
| 53 | + pub team_text: &'static str, | ||
| 52 | /// "add" | "stats" | "times" | "settings" | 54 | /// "add" | "stats" | "times" | "settings" |
| 53 | pub tab: &'static str, | 55 | pub tab: &'static str, |
| 54 | pub toast: Option<String>, | 56 | pub toast: Option<String>, |
modified
src/routes/mod.rs +2 -1 | @@ -14,7 +14,7 @@ use axum::response::{Redirect, Response}; | ||
| 14 | 14 | use axum::routing::{get, post}; |
| 15 | 15 | |
| 16 | 16 | use crate::auth::extractor::AuthUser; |
| 17 | -use crate::charts::{fmt_minutes, team_color}; | |
| 17 | +use crate::charts::{contrast_text, fmt_minutes, team_color}; | |
| 18 | 18 | use crate::db::{invitations, teams as teams_db, timers, users}; |
| 19 | 19 | use crate::error::AppError; |
| 20 | 20 | use crate::render::{Shell, TeamChoiceView, TimerView}; |
| @@ -156,6 +156,7 @@ pub async fn shell( | ||
| 156 | 156 | username, |
| 157 | 157 | team_name: ctx.team_name.clone(), |
| 158 | 158 | team_color: team_color(ctx.team_id), |
| 159 | + team_text: contrast_text(team_color(ctx.team_id)), | |
| 159 | 160 | tab, |
| 160 | 161 | toast, |
| 161 | 162 | invite_count, |
| @@ -14,7 +14,7 @@ use axum::response::{Redirect, Response}; | |||
| 14 | use axum::routing::{get, post}; | 14 | use axum::routing::{get, post}; |
| 15 | 15 | ||
| 16 | use crate::auth::extractor::AuthUser; | 16 | use crate::auth::extractor::AuthUser; |
| 17 | -use crate::charts::{fmt_minutes, team_color}; | 17 | +use crate::charts::{contrast_text, fmt_minutes, team_color}; |
| 18 | use crate::db::{invitations, teams as teams_db, timers, users}; | 18 | use crate::db::{invitations, teams as teams_db, timers, users}; |
| 19 | use crate::error::AppError; | 19 | use crate::error::AppError; |
| 20 | use crate::render::{Shell, TeamChoiceView, TimerView}; | 20 | use crate::render::{Shell, TeamChoiceView, TimerView}; |
| @@ -156,6 +156,7 @@ pub async fn shell( | |||
| 156 | username, | 156 | username, |
| 157 | team_name: ctx.team_name.clone(), | 157 | team_name: ctx.team_name.clone(), |
| 158 | team_color: team_color(ctx.team_id), | 158 | team_color: team_color(ctx.team_id), |
| 159 | + team_text: contrast_text(team_color(ctx.team_id)), | ||
| 159 | tab, | 160 | tab, |
| 160 | toast, | 161 | toast, |
| 161 | invite_count, | 162 | invite_count, |
modified
templates/app.html +2 -4 | @@ -4,14 +4,12 @@ | ||
| 4 | 4 | <header class="app-header"> |
| 5 | 5 | <div class="app-topline"> |
| 6 | 6 | {% if !shell.teams.is_empty() %} |
| 7 | - <a class="team-pill" href="#equipes" aria-label="Changer d'équipe"> | |
| 8 | - <span class="team-dot" style="background:{{ shell.team_color }}"></span> | |
| 7 | + <a class="team-pill" href="#equipes" aria-label="Changer d'équipe" style="background:{{ shell.team_color }};color:{{ shell.team_text }}"> | |
| 9 | 8 | {{ shell.team_name }} |
| 10 | 9 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"></path></svg> |
| 11 | 10 | </a> |
| 12 | 11 | {% else %} |
| 13 | - <span class="team-pill"> | |
| 14 | - <span class="team-dot" style="background:{{ shell.team_color }}"></span> | |
| 12 | + <span class="team-pill" style="background:{{ shell.team_color }};color:{{ shell.team_text }}"> | |
| 15 | 13 | {{ shell.team_name }} |
| 16 | 14 | </span> |
| 17 | 15 | {% endif %} |
| @@ -4,14 +4,12 @@ | |||
| 4 | <header class="app-header"> | 4 | <header class="app-header"> |
| 5 | <div class="app-topline"> | 5 | <div class="app-topline"> |
| 6 | {% if !shell.teams.is_empty() %} | 6 | {% if !shell.teams.is_empty() %} |
| 7 | - <a class="team-pill" href="#equipes" aria-label="Changer d'équipe"> | 7 | + <a class="team-pill" href="#equipes" aria-label="Changer d'équipe" style="background:{{ shell.team_color }};color:{{ shell.team_text }}"> |
| 8 | - <span class="team-dot" style="background:{{ shell.team_color }}"></span> | ||
| 9 | {{ shell.team_name }} | 8 | {{ shell.team_name }} |
| 10 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"></path></svg> | 9 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"></path></svg> |
| 11 | </a> | 10 | </a> |
| 12 | {% else %} | 11 | {% else %} |
| 13 | - <span class="team-pill"> | 12 | + <span class="team-pill" style="background:{{ shell.team_color }};color:{{ shell.team_text }}"> |
| 14 | - <span class="team-dot" style="background:{{ shell.team_color }}"></span> | ||
| 15 | {{ shell.team_name }} | 13 | {{ shell.team_name }} |
| 16 | </span> | 14 | </span> |
| 17 | {% endif %} | 15 | {% endif %} |