judu/ex-egopublic Fork
⑂ 1401cf3
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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
Julien Durillon committed 2026-08-08T18:11:50+02:00 Browse files
1401cf3 parent: f59263c
modified assets/style.css +5 -3
@@ -148,16 +148,18 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
148148
149149 .app-header { padding: var(--space-6) var(--space-4) var(--space-3); }
150150 .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. */
151153 .team-pill {
152154 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;
154156 background: var(--color-accent-2-200); color: var(--color-accent-2-900);
155157 }
156-/* Pastille couleur d'équipe (hash de l'id, posée en style inline). */
158+/* Pastille couleur d'équipe (sheet « Changer d'équipe »). */
157159 .team-dot { width: 12px; height: 12px; border-radius: 999px; flex: none; }
158160 /* Pill-sélecteur (plusieurs équipes) : ouvre la sheet #equipes. */
159161 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); }
161163 .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; }
162164 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }
163165
@@ -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 {
3030 PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize]
3131 }
3232
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+
3362 /// « 45 min », « 2 h », « 12 h 30 »
3463 pub fn fmt_minutes(total: i64) -> String {
3564 let (h, m) = (total / 60, total % 60);
@@ -274,4 +303,15 @@ mod tests {
274303 assert!(PALETTE.contains(&team_color(id)));
275304 assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]);
276305 }
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+ }
277317 }
@@ -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 {
4747 pub username: String,
4848 /// Nom de l'équipe active (pill de l'en-tête).
4949 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).
5151 pub team_color: &'static str,
52+ /// Encre ou crème selon le meilleur contraste sur team_color.
53+ pub team_text: &'static str,
5254 /// "add" | "stats" | "times" | "settings"
5355 pub tab: &'static str,
5456 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};
1414 use axum::routing::{get, post};
1515
1616 use crate::auth::extractor::AuthUser;
17-use crate::charts::{fmt_minutes, team_color};
17+use crate::charts::{contrast_text, fmt_minutes, team_color};
1818 use crate::db::{invitations, teams as teams_db, timers, users};
1919 use crate::error::AppError;
2020 use crate::render::{Shell, TeamChoiceView, TimerView};
@@ -156,6 +156,7 @@ pub async fn shell(
156156 username,
157157 team_name: ctx.team_name.clone(),
158158 team_color: team_color(ctx.team_id),
159+ team_text: contrast_text(team_color(ctx.team_id)),
159160 tab,
160161 toast,
161162 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 @@
44 <header class="app-header">
55 <div class="app-topline">
66 {% 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 }}">
98 {{ shell.team_name }}
109 <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>
1110 </a>
1211 {% 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 }}">
1513 {{ shell.team_name }}
1614 </span>
1715 {% 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 %}